Filter concepts by levelShowing all levels.

Django · Architecture Styles

Architecture Styles

Where the boundary is, which way it points, and what it costs to cross

  • Distribution relocates a boundaryit does not create one — draw it in the monolith first
  • No distributed `atomic()`a split workflow needs an outbox or a saga, not a transaction
  • Take the direction, not the purityDjango's Active Record ORM makes a pure domain model expensive
  • A service layer is the cheap winlogic out of views, models and serializers — and callable from anywhere
  • A bounded context is a vocabulary"order" means something different to fulfilment and to billing

1.Monolith, modular monolith, microservices

A **traditional monolith** is one deployable where any code can import any other code. A **modular monolith** is still one deployable, but the modules have declared boundaries and talk through published interfaces. **Microservices** split those modules into separately deployed services that talk over the network. The roadmap is blunt about the trade: "microservices are not automatically more scalable, more reliable, or easier to maintain".

Key features

  • The question is where the boundary is enforced: nowhere, in code, or in the network
  • A modular monolith keeps one transaction, one deploy, one traceback, cheap refactoring
  • Every extracted call gains timeouts, retries, duplicates and partial failure
  • There is no distributed `atomic()` — a cross-service workflow needs an outbox or a saga
  • "Microservices are not automatically more scalable, more reliable, or easier to maintain"
  • Draw the boundary in the monolith first; a boundary you cannot draw there will not survive a network

What changes when the boundary moves

What changes when the boundary moves
DimensionTraditional monolithModular monolithMicroservices
boundary enforced bynothingcode review, import rules, interfacesthe network
a cross-part call isa function calla function call through a public interfacean HTTP or queue call that can fail
multi-part transaction`atomic()``atomic()`**not possible** — outbox or saga
deployoneoneone per service, coordinated
debugging one requestone tracebackone tracebacktraces across services, correlated by id
scaling one part alonenopartly — separate queues and workersyes
moving the boundary lateran afternoonan afternoona project

What actually justifies extracting a service

What actually justifies extracting a service
ReasonVerdict
one component needs to scale on a different axis**good** — e.g. video transcoding vs the web tier
it has a genuinely different release cadence**good** — daily vs monthly, with different risk
a separate team owns it end to end, including on-call**good** — the boundary already exists socially
it needs a different runtime or language**good** — this cannot be done in one process
"the codebase is too big"bad — that is a module boundary, not a network one
"microservices scale better"bad — the roadmap says so outright
"so teams stop stepping on each other"bad — enforce the boundary in code first, and see
One workflow, three architectures — and what each arrow costs

The same three parts in every column. What changes is what an arrow between them is: an unchecked import, a call through a published interface, or a network request that can time out, retry and duplicate.

  • Three columns, each showing the same workflow — orders, then billing, then notify.
  • In the first column, "traditional monolith", the three parts sit inside one box with plain arrows between them, and a note says any module can import any other module directly.
  • In the second column, "modular monolith", the same three parts sit inside one box but each is drawn as a separate bordered module, with arrows labelled "via the public interface", and a note says one transaction still covers all three.
  • In the third column, "microservices", the three parts are three separate boxes with no enclosing box, joined by dashed red arrows labelled HTTP, and a note says each arrow can time out, retry or duplicate, and no single transaction spans them.
  • A footer states the trade directly: the first two share one transaction, one deploy and one traceback, while the third gains independent deployment and scaling and pays for it with partial failure.

Command

python
from billing.api import raise_invoice   # the module's public interface

The three styles are three places to enforce a boundary: nowhere, in code, or in the network. A modular monolith keeps one transaction, one deploy, one traceback and cheap refactoring while giving you the boundary — a public interface module, primitives across it, and a test that forbids everything else. Microservices trade those for independent deployment, scaling and failure, and charge for it in timeouts, retries, duplicates, sagas and correlated tracing. Extract when the reason survives "what would we gain that a module boundary cannot give us?" — different scaling axis, different release cadence, different team, different runtime — and draw the boundary in the monolith first either way.

2.Layered, clean and hexagonal

All three are rules about **which direction dependencies point**. **Layered** stacks views over services over data access, and each layer may only call the one below. **Clean** and **hexagonal** go further: your business rules sit in the middle and depend on nothing, while the database, the web framework and external APIs are *adapters* plugged in at the edge, depending inwards.

Key features

  • All three are one rule: dependencies point one way, and never in a cycle
  • Layered: views → services → data access, and nothing calls upward
  • Clean/hexagonal: the domain depends on nothing; the ORM and HTTP are adapters
  • Django's ORM is Active Record — a model *is* a row, which cuts against that direction
  • Full purity costs the admin, ModelForms, serializers and a mapping layer
  • Take the direction, not the purity: a service layer plus interfaces for external systems

The three, and what each asks you to give up

The three, and what each asks you to give up
StyleThe ruleCost in a Django project
layeredeach layer calls only the one belowalmost none — mostly discipline about where logic lives
cleanthe domain imports nothing; adapters point inwardsa second object model, plus mapping to and from ORM models
hexagonalports and adapters — the same idea, framed by interfacesthe same, plus an interface per external system

What to adopt in Django, and what to skip

What to adopt in Django, and what to skip
PracticeVerdict in a Django codebase
business logic in services, not views or serializers**adopt** — the highest return of anything here
an interface for each external system (payments, email, search)**adopt** — tests substitute a fake instead of patching internals
never import `request` below the view layer**adopt** — it is what keeps the framework at the edge
a repository wrapping the ORMsometimes — see §101; often a QuerySet method is enough
domain objects that are not Django modelsrarely — you lose admin, ModelForms, serializers, and gain a mapper
no Django import anywhere in the domain packagerarely — the purity is real, and so is the maintenance
One direction of dependency, drawn outside in

Delivery — views, DRF, management commands, Celery tasks

Parses input, calls one service function, renders the result. Holds no business rules, and is the only place `request` exists.

Application — service functions

The workflow: what happens, in what order, inside which transaction. This is the layer most Django projects are missing, and adding it is the single biggest win here.

Domain — the rules themselves

What is true regardless of delivery: which transitions are legal, how a total is computed. In Django this usually lives on models and their managers rather than in a separate object model.

Ports — interfaces for external systems

Payments, email, search, object storage. Declaring the interface is what lets a test substitute a fake instead of patching a library's internals.

Adapters — the ORM, HTTP clients, the broker

Implementations of the ports, and the only code that knows a vendor exists. Swapping one should not reach past this ring.

  1. Delivery — views, DRF, management commands, Celery tasks — Parses input, calls one service function, renders the result. Holds no business rules, and is the only place `request` exists.
  2. Application — service functions — The workflow: what happens, in what order, inside which transaction. This is the layer most Django projects are missing, and adding it is the single biggest win here.
  3. Domain — the rules themselves — What is true regardless of delivery: which transitions are legal, how a total is computed. In Django this usually lives on models and their managers rather than in a separate object model.
  4. Ports — interfaces for external systems — Payments, email, search, object storage. Declaring the interface is what lets a test substitute a fake instead of patching a library's internals.
  5. Adapters — the ORM, HTTP clients, the broker — Implementations of the ports, and the only code that knows a vendor exists. Swapping one should not reach past this ring.

Command

python
def place_order(*, user, items, gateway: PaymentGateway) -> Order:

Layered, clean and hexagonal are one rule with increasing strictness: dependencies point one way and never in a cycle. Layered — views call services, services call data access, nothing calls upward — is cheap and is most of the benefit. Clean and hexagonal put the domain in the middle and make the ORM and HTTP adapters, which cuts against Django's Active Record ORM and costs you the admin, ModelForms, serializers and a mapping layer. So take the direction, not the purity: business logic in a service layer, `request` never below the view, and a declared interface for each genuinely external system so tests substitute a fake instead of patching a vendor's internals.

3.Domain-driven design and event-driven, as styles

**Domain-driven design** decides *where* the boundaries go: you find the parts of the business that have their own language and rules, and make each one a bounded context that owns its data. **Event-driven architecture** decides *how* the parts talk: instead of calling each other, they publish facts and react to them. They answer different questions, and they are frequently used together — DDD draws the lines, events cross them.

Key features

  • DDD decides where boundaries go; event-driven decides how boundaries are crossed
  • A bounded context lets each part keep its own model and vocabulary for the same word
  • An aggregate answers a concrete Django question: what does one `atomic()` block cover?
  • Events let a fifth consumer appear without the producer changing — that is the benefit
  • The costs are at-least-once delivery, dedup, the outbox, and eventual consistency
  • Neither pays for a single-team CRUD app; together, they make it harder, not easier

The two styles side by side

The two styles side by side
DimensionDomain-driven designEvent-driven architecture
answerswhere do the boundaries go?how do the parts communicate?
unitbounded context, aggregateevent, topic, subscription
in Djangoapp boundaries, service functions, `atomic()` scopeoutbox rows, Celery tasks, a broker
pays whenone word means different things to different teamsseveral consumers need the same fact
coststranslation at every boundary, more ceremonyduplicates, ordering, eventual consistency
skip it whenone team, one meaning per wordone consumer, and it can be a function call
Two different questions, often asked together

Domain-driven design — where the lines go

  • The same word means different things to different parts
  • A bounded context owns its model, its vocabulary and its data
  • Translation at the boundary is explicit, not accidental
  • An aggregate is one entry point and one consistency rule
  • In Django: what one `atomic()` block covers, and what may be stale
  • Skip the ceremony when there is one team and one meaning

Event-driven — how the lines are crossed

  • Publish a fact in the past tense, not a command
  • A new consumer needs no change in the producer
  • At-least-once delivery: deduplicate on the event id
  • Ordering only within a partition, if you set the key
  • The outbox is what stops the state and the event disagreeing
  • Skip it when there is one consumer and a function call would do
  • Domain-driven design — where the lines go
    • The same word means different things to different parts
    • A bounded context owns its model, its vocabulary and its data
    • Translation at the boundary is explicit, not accidental
    • An aggregate is one entry point and one consistency rule
    • In Django: what one `atomic()` block covers, and what may be stale
    • Skip the ceremony when there is one team and one meaning
  • Event-driven — how the lines are crossed
    • Publish a fact in the past tense, not a command
    • A new consumer needs no change in the producer
    • At-least-once delivery: deduplicate on the event id
    • Ordering only within a partition, if you set the key
    • The outbox is what stops the state and the event disagreeing
    • Skip it when there is one consumer and a function call would do

Command

python
# a context boundary, crossed by a fact rather than an import
publish("order.placed", {"order_id": order.id})

DDD answers where the boundaries go, event-driven answers how they are crossed, and they compose: a bounded context owns its model and vocabulary, and publishes facts at its edge instead of letting other parts read its tables. Two pieces are worth taking into any Django project — writing down what a word means inside a context, and using the aggregate question to decide what one `atomic()` block covers and what may be stale. Everything else costs: translation at boundaries, ceremony, at-least-once delivery, deduplication, the outbox, and eventual consistency wherever a user can see it. Spend that complexity when several parts genuinely disagree about a word, or several consumers need the same fact — not on a single-team CRUD application.

Remember

  • The roadmap says it outright: microservices are not automatically more scalable, more reliable, or easier to maintain.
  • Independent deployment is a property of the contract, not of the topology — expand and contract applies to APIs too.
  • A boundary that is not checked in CI is a convention, and conventions decay at the first deadline.
  • Pass primitives across a module boundary, not ORM objects, or an extraction becomes a rewrite.

Pro tip

  • Before extracting a service, ask what you would gain that a module boundary cannot give you. Different scaling axis, release cadence, team or runtime are good answers; "the codebase is too big" is not.
  • Use the aggregate question as a design tool: what does this `atomic()` block cover, and what is allowed to be a moment behind? It converts a lot of architectural discussion into one concrete decision.

Next up

  • Service Layer
  • Repository Pattern
  • Domain-Driven Design

Sources

  • Django transactions topic guide — docs.djangoproject.com/en/5.2/topics/db/transactions/
  • Django models topic guide — docs.djangoproject.com/en/5.2/topics/db/models/
  • Every Django claim checked against Django 5.2 LTS docs · checked 2026-09-05

Spotted a mistake on this page? Tell us.