Skip to content

JargeZ/demo-guidline-project

Repository files navigation

General Guidelines for Django Projects

How to start develop

  1. Install https://taskfile.dev/installation/, usually just brew install go-task

  2. Set up your Python environment using the IDE or run poetry install, which will do it automatically

  3. Run configurations for IDEA are located in the .run folder and should be detected automatically

  4. There is one main command that runs all checks: task check It must pass before any push. You can also add it as a pre-push hook.

  5. Tasks you might need individually are defined in Taskfile.yaml:

    1. db:make / db:migrate – create and apply database migrations. Use db:upgrade if you're lucky.
    2. lint – run linters separately
    3. test – run tests separately
  6. One branch per feature. Rebase onto main, DO NOT merge. Use amend, fuxip and force push in feature branches to keep commit history clean.


General guidelines

Business logic

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 NotImplementedError

Right 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.


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 = total

Sometimes services need a shared context - e.g., in multi-tenant setups or with entities like Event/LegalEntity/Merchant.

  • TODO: cover in docs

Framework and CRUD integration

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 bills
  • POST /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.


API structure

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

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.


Power of libraries

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-export A 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-actions There’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-filter A must-have. Views should never handle filtering manually. Always use FilterSets. This brings centralization, separation of concerns, and works great with drf-spectacular.

  • drf-spectacular Should 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-errors A must-have for consistent API error formatting.

  • drf-pydantic DRF 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-progress Provides progress API for Celery tasks. It's integrated at the service level - ask for examples if needed.

  • django-managerie Useful for running maintenance tasks in rare cases, but don’t use it as a main mechanism.

  • django-healthchecks Prebuilt health check endpoints.

  • django-stubs Mandatory typing support for Django.

  • django-json-widget Useful admin enhancement for JSON fields.

  • more-itertools Helps avoid writing your own utilities like batched() and others.

  • cachetools Great for optimizing bottlenecks with caching.

  • vcrpy A must-have for testing external integrations.

  • pytest-freezer A must-have for testing time-dependent logic.

  • pytest-clarity Enhances test failure readability.

  • pytest-django Mandatory for Django testing - it supports all test infrastructure.

  • syrupy Snapshot 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.


TODO

  • Convert guideline to mkdocs and 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

About

This is a demo project using the example of business logic for integration with payments. The goal of the project is to collect documentation with guidelines and examples of approaches to development. Mostly WIP at this moment

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors