-
Install https://taskfile.dev/installation/, usually just
brew install go-task -
Set up your Python environment using the IDE or run
poetry install, which will do it automatically -
Run configurations for IDEA are located in the
.runfolder and should be detected automatically -
There is one main command that runs all checks:
task checkIt must pass before any push. You can also add it as a pre-push hook. -
Tasks you might need individually are defined in
Taskfile.yaml:db:make/db:migrate– create and apply database migrations. Usedb:upgradeif you're lucky.lint– run linters separatelytest– run tests separately
-
One branch per feature. Rebase onto
main, DO NOT merge. Use amend, fuxip and force push in feature branches to keep commit history clean.
Any code that contains domain-specific decisions - not only technical stuff - is considered business logic. For us, this is the application layer.
You can think of business logic as a set of actions. Each business process is made up of actions.
A business process here is not only something visible to end users (like review flow), but also internal automation steps used under the hood.
It’s important to define the boundaries of actions based on meaning.
Example: "Create Invoice" It may consist of:
- parsing a PDF file
- converting parsed data into a domain model
- creating an invoice with additional logic
This is a big, non-atomic but still a business action.
Why?
- We may want to create invoices from other sources (e.g., manual input)
- We may want to parse invoice PDFs but use them for other purposes
- Domain model creation might come from parsed PDF or elsewhere
The main idea is to identify the smallest meaningful, reusable business actions.
Each action can reuse other actions.
In code, each application should have an actions package.
A base class for business actions:
class BaseBusinessAction:
pass
# Optional
@abstractmethod
def execute(self, *args, **kwargs):
raise NotImplementedErrorRight now it’s empty, but in the future it can be extended for high-level logic, like:
- Auditing: which user did what and when
- Automatic tracing/logging of method calls
- Contract enforcing abstraction and method consistency
All business actions must inherit from this base class. It helps developers understand and discover existing logic via IDE navigation.
Example of using an action:
llama_parsed = LlamaCloudDocumentParseAction().execute(
file=FileUtils.filefield_to_bytesio(document.file),
)
document.llama_parsed = llama_parsed.model_dump(mode="json")Example in an API view:
@scoped_permission_require(PermissionAction.DOMAIN_TRANSACTION_VIEW)
@extend_schema(
request=AssignTransactionSerializer,
responses={200: ShortTransactionSerializer},
)
@action(detail=True, methods=["POST"])
@atomic
def assign(self, request: ProjectDomainRequest, *args, **kwargs):
transaction: DomainTransaction = self.get_object()
# Parse input from request
assign_serializer = self.create_contexted_serializer(AssignTransactionSerializer, data=request.data)
assign_serializer.is_valid(raise_exception=True)
unit_price: Money = assign_serializer.validated_data["unit_price"]
transaction_mean: TransactionMean = assign_serializer.validated_data["transaction_mean"]
allocate_remaining: RemainingAllocationOption = assign_serializer.validated_data["allocate_remaining"]
# Start orchestrating business logic
trx = TrxWrapperService(transaction)
transaction_amount = trx.reader.get_unrecognized_amount()
# Simple validation in controller (view)
if transaction_amount is None:
raise ValidationError("Transaction has no unrecognized amount")
if transaction_amount.currency != unit_price.currency:
raise ValidationError("Currency mismatch")
# Glue logic (still orchestration)
line = (
GenericLineBuilder()
.for_as_meaning(mean=transaction_mean)
.with_amount(
price=unit_price,
quantity=assign_serializer.validated_data.get("quantity"),
)
.finalize(description=assign_serializer.validated_data.get("memo"))
)
# Execute business action
AssignTransactionToItem().with_transaction(transaction).execute(
line=line,
item=assign_serializer.validated_data["domain_item"],
remaining=allocate_remaining,
)
# Return serialized response
serializer = self.create_contexted_serializer(ShortTransactionSerializer, instance=transaction)
return Response(serializer.data, status=status.HTTP_200_OK)Important:
- Views (controllers) just glue logic together and pass input/output.
- They should never contain business logic themselves.
- Actions and services are not extensions of the view - they are independent and define their own fully-typed interfaces.
Note
No business logic in models, views, tasks, or admin.
Always go through actions and services.
Service classes also have their place. They are used less often than actions.
Think of a service as a controller with too much logic or as a long-running business workflow.
Services are useful when:
- There are multiple steps in a process
- You need state or progress tracking
- You're considering to extract the logic into a separate module later
General rules:
- Actions don’t take constructor arguments (reserved for high-level use)
- Actions can use other actions but not services
- Services can use both actions and other services
Example base service:
class BaseService:
__task: Task | None = None
__progress_recorder: ProgressRecorder
__progress_counter: int = 0
__progress_total: int = 10
__progress_last_description: str = ""
def bind_task(self, task: Task):
self.__task = task
self.__progress_recorder = ProgressRecorder(task)
def connect(self, other: "BaseService"):
if self.__task is not None:
other.bind_task(self.__task)
def set_progress_total_default(self, total: int):
self.__progress_total = totalSometimes services need a shared context - e.g., in multi-tenant setups or with entities like Event/LegalEntity/Merchant.
- TODO: cover in docs
When using a framework, remember: It runs your code, not you. Your job is to place your code WHERE THE FRAMEWORK EXPECTS IT.
Read documentation broadly quick, to understand what is already done for you. To not invent something that already exists and save your time.
In our case, this is mostly about API + models (ModelViewSet + ModelSerializer).
It’s important to understand:
- The way to work with api as with only Models covers only ~20% of what you’ll need
- You are NOT REQUIRED to only use ModelViewSets always, please remember that.
- BUT, if your logic revolves around models (80% of the time), you must start with them.
- If you need to extend the API, use actions and filters. And feel free to use custom serializers for default actions like create/update.
Example: you have a resource like Bill.
Basic API:
GET /bill– list billsPOST /bill/– create a bill
By default, you are getting a crud-api that represents the Bill resource.
That’s a good starting point.
Then you may add resource actions:
- On instance of resource:
POST /bill/123/issue - On abc resource itself:
POST /bill/import-from-file,GET /bill/summary
Please note that in this case, the response structure is not just a filtered list of resource instances. It's a completely different structure.
There is an intuitive temptation to create an action like GET /bill/unpaid.
However, this kind of logic is actually a variation of a list action and MUST be implemented using FilterSets.
APIs must be well-structured.
- TODO: cover in docs
Use a clear, predictable structure for your API endpoints. This helps with maintainability and discoverability.
in core.urls.v1.py:
urlpatterns = [
path("core/", include("core.urls.core")),
path("bill/", include("apps.bill.urls")),
path("event/", include("apps.event.urls")),
]Your main Django app (with settings.py) should define the top-level API router:
in core.urls:
api = [
path("v1/", include("core.urls.v1")),
]
urlpatterns = [
path("api/", include(api)),
path("", include("django_prometheus.urls")),
path("health/", include("django_healthchecks.urls")),
path("adm/", admin.site.urls),
path("silk/", include("silk.urls", namespace="silk")),
FlowerProxyView.as_url(),
path("api/schema/", schema_view, name="schema"),
]Each app has its own urls.py:
from apps.reporting.views.common_reports import CommonReportsViewSet
from apps.reporting.views.event_specific import EventSpecificReportsViewSet
router = routers.DefaultRouter()
router.register("common", CommonReportsViewSet, basename="CommonReportsViewSet")
router.register("event-specific", EventSpecificReportsViewSet, basename="EventSpecificReportsViewSet")
urlpatterns = [*router.urls]Naming is 80% of maintainability. Use clear, predictable names - it enables fast IDE search.
Testing principles:
- Keep logic in tests to a minimum
- Every app-logic case or endpoint should have at least one test to ensure regression prevention
- Use VCR for real external integrations, if it's a main part of tested logic
- Use mocks or cached fixtures for secondary integrations (if its not the main focus of the test, e.g., external enrichment)
- Don’t assert many non-critical fields - use snapshots to bake the curent state, proof with eyes and ensure regression prevention
When changing business logic, make sure to pay attention to running tests.
The pyproject.toml file contains the configuration for pytest.
At some point, you may need to uncomment the "--snapshot-update" option and run tests with updated snapshot data - but only after you’ve manually reviewed the output and confirmed it’s correct.
When working on integrations, you will also want to control the --record-mode option of the vcr library.
Focus on business logic. Use libraries for everything else.
Many problems have already been solved for us, and we’re most effective when we focus on implementing business logic and spend less time on infrastructure and tools.
Any task that is not part of the business domain can probably be solved with minimal custom code, for example:
- File handling
- Authentication
- Error handling
- Test infrastructure
- Mocks
- Monitoring
These are all examples of non-business tasks, and similar problems have been solved many times before. You should always start by looking for existing solutions or guides.
The main rule in everything - LESS CODE IS BETTER.
Here are libraries that were useful in my experience:
-
django-import-exportA helpful tool for working with data in the Django admin. Useful in early stages when you need to work with data but haven’t built an API or frontend yet. -
django-object-actions+django-modal-actionsThere’s a golden rule: use the admin for internal needs as much as possible, but never customize it in a deep or invasive way. Don’t override forms or add complex logic there. If you feel like doing that - you probably need a custom frontend. These libraries help you deliver features quickly, especially for operations teams, and the logic can easily be reused in an API if it's already implemented as actions or services. -
django-filterA must-have. Views should never handle filtering manually. Always use FilterSets. This brings centralization, separation of concerns, and works great withdrf-spectacular. -
drf-spectacularShould always be used to inspect your API through Swagger. Maintaining an OpenAPI spec greatly reduces frontend effort thanks to code generators like orval.dev (preferred) or kubb (more advanced setup). -
drf-standardized-errorsA must-have for consistent API error formatting. -
drf-pydanticDRF serializers are powerful and well-integrated, but this library makes writing small request/response structures more enjoyable. Not a best practice, but it also allows reuse of business models in responses (only if done carefully - avoid doing this all the time). -
celery-progressProvides progress API for Celery tasks. It's integrated at the service level - ask for examples if needed. -
django-managerieUseful for running maintenance tasks in rare cases, but don’t use it as a main mechanism. -
django-healthchecksPrebuilt health check endpoints. -
django-stubsMandatory typing support for Django. -
django-json-widgetUseful admin enhancement for JSON fields. -
more-itertoolsHelps avoid writing your own utilities likebatched()and others. -
cachetoolsGreat for optimizing bottlenecks with caching. -
vcrpyA must-have for testing external integrations. -
pytest-freezerA must-have for testing time-dependent logic. -
pytest-clarityEnhances test failure readability. -
pytest-djangoMandatory for Django testing - it supports all test infrastructure. -
syrupySnapshot testing made easy. Helps lock down functional behavior with minimal test code. Ask for examples if needed.
And many more - depending on the specific problem you're solving.
- Convert guideline to
mkdocsand formalize structure - Create junior section with links to basic docs
- Split into files, move code comments into code
- Separate library guide files
- Add examples for integration/unit tests
- Describe usage of factory_boy and test factories