Filter concepts by levelShowing all levels.

Django · Section 99

Architecture Styles

Level
advanced
Read
30 min
Concepts
3

The eight styles in this section answer two questions between them, and separating those questions makes the trade-offs legible. The first is where a boundary is enforced. In a traditional monolith the answer is nowhere: any module can import any other, so coupling grows unopposed until every change touches everything. A modular monolith enforces it in code — each module publishes a small interface, everything else is private, primitives cross the boundary rather than ORM objects, and a test forbids the imports that would undo it. Microservices enforce it in the network, and that is a change of physics rather than of discipline: every call that used to be a function call can now be slow, refused, duplicated or half-completed, a workflow that fitted in one `atomic()` block now needs an outbox or a saga because no transaction spans two databases, one traceback becomes a distributed trace correlated by id, and one migration becomes a coordinated release. In exchange you get independent deployment, scaling and failure — real benefits, worth paying for when a component genuinely scales on a different axis, releases on a different cadence, is owned end to end by another team, or needs a different runtime. The roadmap states the corrective plainly: microservices are not automatically more scalable, more reliable, or easier to maintain, and a well-structured modular monolith is often an excellent architecture. The practical order follows — draw the boundary in the monolith first, because it is cheap and reversible, and because a boundary you cannot draw in one process will not survive being stretched over a network. The second question is which way dependencies point. Layered says each layer calls only the one below: views parse and render, services hold the workflow, models persist. Clean and hexagonal go further, putting the domain in the middle with no framework imports and making the ORM, HTTP and every external API adapters that point inwards. The direction is right; the purity is expensive in Django specifically, because the ORM is Active Record — a model *is* a row — so a faithful implementation means a second object model, a mapping layer, and giving up the admin, ModelForms, serializers and much of the ecosystem. Take the parts that pay in almost every project: business logic in a service layer rather than in views or serializers, `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. Reserve the full separation for a domain whose rules are intricate, long-lived and expected to outlast the framework. The last two styles cut across the others. Domain-driven design decides where the lines go — a bounded context lets each part keep its own model and vocabulary, because "order" means something different to fulfilment, billing and support, and its aggregate idea answers a concrete Django question: what does one `atomic()` block cover, and what may be stale? Event-driven decides how the lines are crossed — publish a fact rather than call a neighbour, so a fifth consumer can appear without the producer changing, at the cost of at-least-once delivery, deduplication, the outbox and eventual consistency. They compose well, and neither pays for a single-team CRUD application.

What is true here

  1. Three places to enforce a boundary: nowhere, in code, in the network.
  2. A service split buys independent deployment and pays in partial failure.
  3. Dependencies point one way; in Django, take that direction without the purity.
  4. DDD decides where the lines go; events decide how they are crossed.
  5. Every one of these is a cost, so name what it buys before adopting it.

What you will be able to do

  • Argue for or against a service extraction using the properties it would actually change
  • Make a module boundary explicit and enforce it with a test
  • Place business logic so it is callable from a view, a command and a task alike
  • Decide when a bounded context or an event bus is worth its complexity
Two questions, and the order to answer them in

Where is the boundary?

nowhere · in code · in the network

Which way do deps point?

layered → clean → hexagonal

Where do the lines go?

bounded contexts, by vocabulary

How are they crossed?

a published fact, not an import

What did it buy?

name it, or do not spend the complexity

  1. Where is the boundary? — nowhere · in code · in the network
  2. Which way do deps point? — layered → clean → hexagonal
  3. Where do the lines go? — bounded contexts, by vocabulary
  4. How are they crossed? — a published fact, not an import
  5. What did it buy? — name it, or do not spend the complexity

Where the boundary is enforced

Nowhere, in code, or in the network — and what the last one costs.

Monolith, modular monolith, microservices

coreadvanced

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

Think of it as

The three are not a maturity ladder; they are three answers to one question — where do you enforce the boundary between parts of your system? In a traditional monolith the answer is "nowhere", so `orders/views.py` can import `billing.models` and reach into another team's tables, and over time it does, until every change touches everything. That coupling is the real problem people are trying to escape, and it is worth naming precisely, because the usual escape route treats a *code* problem with a *distribution* solution. The modular monolith enforces the boundary in code instead. Each module exposes a small public interface — a service function, a set of events — and everything else is private; the import graph is checked, cross-module foreign keys are avoided or made explicit, and calls between modules go through the front door. You keep the things a single process gives you for free: one `transaction.atomic()` block covering a multi-model workflow, a function call that cannot time out, one deploy, one place to look in a traceback, and refactoring across a boundary in an afternoon. Microservices move the boundary into the network, and the honest way to describe that is that you exchange a set of easy problems for a different set of hard ones. Every call that used to be a function call can now be slow, refused, duplicated or half-completed, so each one needs a timeout, a retry policy, an idempotency key and a fallback. A workflow that spanned two modules in one transaction now spans two databases and needs an outbox or a saga, because there is no distributed `atomic()` block. Debugging needs correlation ids and distributed tracing to reconstruct what one process used to show in a single traceback. And a schema change that used to be one migration becomes a coordinated release across services. In exchange you get independent deployment, independent scaling and independent failure — genuine benefits, and worth paying for when you have a component whose scaling or release cadence really is different, or when team boundaries have made a shared deployment a bottleneck. The practical guidance is the order: make the modular boundary first, inside the monolith, because it is cheap, reversible and the prerequisite for any split that could work. If you cannot draw a clean boundary in one process, extracting it over a network will not produce one — it will produce the same coupling with latency and partial failure added, which is the shape people mean when they say a system has become harder to change after the split, not easier.

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

What we're doing: Turn a traditional monolith into a modular one by making the boundary explicit and checkable — without deploying anything new.

billing/api.py + tests/test_boundaries.pypython
# billing/api.py — the ONLY thing other modules may import.
"""Billing's public interface.

Everything else in this package is private. Keeping the surface small
is what makes the module extractable later: this file is the contract
a future billing service would have to honour.
"""


def raise_invoice(*, order_id: int, amount_cents: int, currency: str) -> int:
    """Return the invoice id. Takes primitives, not ORM objects, so the
    caller does not depend on billing's models — which is exactly the
    dependency that would have to be untangled during an extraction."""
    ...


def invoice_status(*, invoice_id: int) -> str:
    ...


# orders/services.py — the caller
from billing.api import raise_invoice          # front door

def place_order(order, *, actor):
    with transaction.atomic():
        order.transition_to(Order.Status.PLACED, actor=actor)
        # In-process, so this is inside the SAME transaction as the
        # order write. That single property is what a modular monolith
        # keeps and a service split gives away.
        invoice_id = raise_invoice(
            order_id=order.id, amount_cents=order.total_cents,
            currency=order.currency,
        )
    return invoice_id


# tests/test_boundaries.py — the boundary is only real if it is checked
FORBIDDEN = [
    ("orders", "billing.models"),
    ("orders", "billing.services"),
    ("notify", "billing.models"),
]

def test_modules_only_use_public_interfaces():
    for package, forbidden_import in FORBIDDEN:
        offenders = imports_matching(package, forbidden_import)
        assert not offenders, (
            f"{package} imports {forbidden_import}; use billing.api instead. "
            f"Offending files: {offenders}"
        )
1–7
A named interface module is the whole technique. Once "import from `billing.api` only" is a rule, the boundary has a location — and a future extraction has a contract already written.
10–13
Primitives across the boundary, not ORM objects. Passing an `Order` instance would make billing depend on orders' models, which is the coupling that makes an extraction a rewrite rather than a move.
26–33
One `atomic()` block covering both modules. This is the concrete thing a service split costs: across a network there is no shared transaction, and the same workflow needs an outbox or a saga.
37–42
The forbidden-import list makes the boundary enforceable rather than aspirational. Without a check, a boundary is a convention that decays at the first deadline.
44–50
The failure message names the fix and the offending files. A boundary test that only says "assertion failed" gets deleted by whoever hits it at 17:00 on a Friday.

Why this works: The boundary exists in code, is checked automatically, passes only primitives, and still runs inside one transaction — so the module could be extracted later, and does not have to be today.

Extracting a service to solve a code-coupling problem

Wrong

text
"orders and billing keep breaking each other, let's split billing out"
-> same coupling, now over HTTP: every call needs a timeout, a retry,
   an idempotency key, and a saga for what used to be one transaction

Better

text
1. billing/api.py: the public interface, primitives only
2. a boundary test forbidding every other import
3. live with it for a quarter — if the interface stayed stable,
   extraction is now a move rather than a rewrite

What you see: A release train: neither service can deploy without the other, every feature needs a coordinated change in two repositories, and the incident count goes up because failures that used to be exceptions are now timeouts.

Why: Distribution does not create a boundary; it relocates one. If `orders` reaches into `billing`'s tables today, extracting billing turns those reaches into HTTP calls with the same shape and the same assumptions, plus latency, partial failure and duplicate delivery. The coupling is unchanged and the cost of every interaction has gone up. Drawing the boundary in the monolith is the cheaper experiment and the honest test: if the interface can stay small and stable for a few months of real feature work, the split is a mechanical move afterwards. If it cannot, the split would have produced a distributed version of the same tangle — which is the outcome the roadmap is warning about when it says microservices are not automatically easier to maintain.

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.

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

Together

python
# Modular monolith: a call through the front door, still in-process.
from billing.api import raise_invoice     # billing/api.py is the interface
# NOT: from billing.models import Invoice

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

Together

text
Extract when the reason survives this question:
"what would we gain that a module boundary cannot give us?"

Remember: 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.

See also: layered clean and hexagonal · domain driven design and event driven styles · what belongs in a service function · eventual consistency outbox and versioning

Advertisement

Which way dependencies point

Layered, clean and hexagonal — and which parts of them pay in Django.

Layered, clean and hexagonal

coreadvanced

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.

Think of it as

The shared idea is that dependencies should point one way, because a cycle is what makes a codebase impossible to change safely. Layered says it simply: presentation calls application, application calls data, and nothing calls upward — so a template never queries, and a model never renders. Even that modest rule removes most of what makes a Django project unpleasant at scale, because the usual mess is a view that does business logic, a model that sends email, and a serializer that decides pricing. Clean and hexagonal push the arrow further. They put the domain — the rules that would still be true if you rewrote the web layer — at the centre, with no imports of Django, no ORM, no HTTP. Everything technical becomes an adapter at the edge: the ORM is one implementation of a repository *port*, the payment provider is one implementation of a payments port, and the web framework is a delivery mechanism. The pay-off is that you can test the rules without a database and swap an adapter without touching them. Then comes the part that decides whether this is good advice for *your* Django project. Django's ORM is Active Record: a model is a row and knows how to save itself. That is the opposite of the dependency direction clean architecture asks for, so a faithful implementation means defining domain objects that are not models, mapping them to and from models at the boundary, and giving up much of what makes Django productive — the admin, ModelForms, DRF serializers, `select_related`, model validation, the whole ecosystem that assumes it can see your models. Teams that do this in Django usually end up with two parallel object models and a mapping layer nobody enjoys maintaining. So the honest position is that the *direction* is right and the *purity* rarely pays. What is worth taking, in almost every Django project: put business logic in a service layer rather than in views, models or serializers; depend on interfaces for genuinely external systems — payments, email, search — so tests can substitute a fake without patching internals; and keep the framework at the edges by never importing `request` below the view. What is worth taking only when the situation is unusual: a full domain model independent of the ORM, which earns its keep when the rules are genuinely intricate and long-lived, when they must outlive the framework, or when a single business concept is spread across several storage systems.

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

What we're doing: Take the useful half of clean architecture in a Django project: a service layer, a port for the external system, and models that stay models.

orders/ports.py + orders/services.py + testspython
# orders/ports.py — the interface, not an implementation.
from typing import Protocol


class PaymentGateway(Protocol):
    """What orders needs from a payment provider — and nothing more.

    Defined here, in the module that USES it, so the dependency points
    inwards: the adapter imports this, and this imports no adapter.
    """

    def charge(self, *, amount_cents: int, currency: str, token: str) -> str:
        """Return the provider's charge id, or raise PaymentDeclined."""


# orders/services.py — the application layer.
def place_order(*, user, items, gateway: PaymentGateway) -> Order:
    # No request, no HttpResponse, no serializer: this function is
    # callable from a view, a management command, a Celery task or a
    # test, which is exactly what "keep the framework at the edge" buys.
    with transaction.atomic():
        order = Order.objects.create_for(user=user, items=items)
        # A domain rule, expressed on the model where Django puts it.
        # A separate non-ORM domain object would buy purity here and
        # cost the admin, ModelForms and every serializer.
        order.assert_can_be_paid()

    charge_id = gateway.charge(
        amount_cents=order.total_cents, currency=order.currency,
        token=order.payment_token,
    )
    # Outside the atomic block on purpose: a network call inside a
    # transaction holds a database connection for the provider's latency.
    order.mark_paid(charge_id=charge_id)
    return order


# orders/adapters.py — one implementation, the only file that names a vendor
class StripeGateway:
    def charge(self, *, amount_cents, currency, token) -> str: ...


# tests/test_place_order.py — no patching, no network, no vendor
class FakeGateway:
    def __init__(self): self.charges = []
    def charge(self, *, amount_cents, currency, token) -> str:
        self.charges.append(amount_cents)
        return "ch_test_1"

def test_places_order_and_charges_once():
    gateway = FakeGateway()
    order = place_order(user=user, items=items, gateway=gateway)
    assert order.status == Order.Status.PAID
    assert gateway.charges == [order.total_cents]
5–10
The interface lives with the code that needs it, not with the implementation. That single placement is the dependency inversion: the adapter depends on `orders`, and `orders` depends on no adapter.
17–20
The service takes domain arguments rather than a request. It is then callable from a view, a command, a task or a test without any of them knowing about the others.
22–26
The domain rule sits on the model, which is where Django puts it. This is the deliberate impurity: a separate domain object would satisfy clean architecture and cost the admin, ModelForms and serializers.
28–34
The network call is outside the transaction, because holding a database connection for a provider's latency is how a connection pool is exhausted by an unrelated outage.
43–54
The test substitutes an implementation instead of patching a library's internals. Tests that patch internals break when the library is upgraded; tests that use a port break only when your own interface changes.

Why this works: Business logic is callable without HTTP, the vendor is named in exactly one file, and the test needs no network and no patching — which is the whole practical benefit, without a second object model.

Patching a vendor library instead of declaring a port

Wrong

python
@patch("stripe.Charge.create")            # reaching into someone else's module
def test_place_order(mock_create):
    mock_create.return_value = {"id": "ch_1"}
    ...

Better

python
def test_place_order():
    order = place_order(user=user, items=items, gateway=FakeGateway())

What you see: A test suite that breaks on a dependency upgrade with errors about attributes that no longer exist, in tests for code you did not change.

Why: Patching binds your tests to a third party's internal structure, which is not part of its contract and changes between releases. It also lets the test pass while the real call site is wrong, because the patch replaces something you never verified you were calling correctly. A port inverts this: you declare the small interface your code actually needs, one adapter implements it against the vendor, and tests use a fake that satisfies the same interface. The vendor's internals can then change freely, and the only place that has to keep up is the adapter — one file, with an integration test of its own.

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.

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

Together

python
# Layered, and enough for most Django projects:
# views.py       -> parses the request, calls a service, renders
# services.py    -> the business rules, transactions, orchestration
# models.py      -> persistence, constraints, model-level invariants

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

Together

python
# The pragmatic Django version of "ports": an interface for the thing
# that is genuinely external, and models left as models.
class PaymentGateway(Protocol):
    def charge(self, *, amount_cents: int, token: str) -> str: ...

Remember: 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.

See also: monolith modular monolith and microservices · domain driven design and event driven styles · what belongs in a service function · manager and queryset pairing

Advertisement

Drawing the lines, and crossing them

Domain-driven design and event-driven architecture as styles, not implementations.

Domain-driven design and event-driven, as styles

standardadvanced

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

Think of it as

Treat these as two axes rather than two options. DDD is about carving. Its useful core, stripped of ceremony, is that the same word means different things in different parts of a business — "order" to fulfilment is a set of items and an address, to billing it is an amount and a tax treatment, to support it is a conversation — and that trying to serve all of those with one model produces a class with forty fields that nobody can change. A bounded context is the decision to let each part keep its own model and its own vocabulary, with a deliberate, explicit translation at the boundary. The related idea worth keeping is the aggregate: a cluster of objects with one entry point and one consistency rule, which in Django terms answers "what does this `atomic()` block cover, and what is allowed to be stale?" — a genuinely useful question with a direct implementation. What to be careful about is the ceremony. Value objects, repositories, factories, domain events, application services and anti-corruption layers are a coherent set for a complex domain, and in a CRUD-shaped Django app they are five extra indirections for a form that saves a record. Event-driven is about connecting. Its architectural claim is that if parts communicate by publishing facts, a new consumer can be added without the producer changing, which is a real and large benefit for fan-out. The costs are equally real and are the subject of §89: at-least-once delivery, ordering only within a partition, deduplication as your responsibility, the dual-write problem the outbox solves, and eventual consistency wherever a user can observe it. The two combine naturally — a bounded context publishes events at its own boundary, and other contexts subscribe rather than reach into its tables — which is the shape that makes an eventual extraction to a service straightforward. The judgement to carry away is about when to spend the complexity. Bounded contexts pay when several parts of the business genuinely disagree about what a word means, or when different teams own different parts. Events pay when several consumers need the same fact, when a producer must not depend on consumers, or when work should continue after the response is returned. Neither pays for a single-team CRUD application, and adopting both there produces a distributed, eventually-consistent version of a problem that a service function and a foreign key had already solved.

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

What we're doing: Draw one boundary and cross it with an event, so billing can change its rules without touching orders.

orders/services.py + billing/consumers.pypython
# ---- orders context ---------------------------------------------------
# "Order" here means items, an address and a fulfilment state. It does
# NOT mean tax treatment, which is billing's word for its own model.

def place_order(order, *, actor):
    with transaction.atomic():
        # The aggregate boundary, made concrete: this transaction covers
        # the order and its lines, and nothing outside the context.
        order.transition_to(Order.Status.PLACED, actor=actor)
        OutboxMessage.objects.create(
            topic="order.placed",
            payload={
                "order_id": order.id,
                "customer_id": order.customer_id,
                "total_cents": order.total_cents,
                "currency": order.currency,
            },
        )
    # orders does not know billing exists. Adding a fifth consumer next
    # year changes nothing in this file — which is the whole benefit.


# ---- billing context --------------------------------------------------
# Billing keeps its OWN model. It stores order_id as a plain integer,
# not a ForeignKey, because a ForeignKey would be billing reaching into
# orders' tables — the coupling the boundary exists to prevent.

def on_order_placed(event):
    # At-least-once delivery: claim the event id in the SAME transaction
    # as the work, so a redelivery is a no-op rather than a second invoice.
    with transaction.atomic():
        _, created = ProcessedEvent.objects.get_or_create(
            event_id=event["event_id"],
        )
        if not created:
            return                    # already handled — acknowledge and stop
        Invoice.objects.create(
            order_id=event["payload"]["order_id"],
            amount_cents=event["payload"]["total_cents"],
            # Billing's own rule, in billing. "We only invoice above
            # £5" belongs here, never in the order service.
            tax_treatment=derive_tax_treatment(event["payload"]),
        )
1–3
The context is named by what the word means inside it. Writing that down is most of DDD's practical value: it is the sentence that stops "order" growing a tax field.
7–9
The aggregate as a Django question. The transaction covers the order and its lines — everything else is another context and is allowed to be a moment behind.
10–18
The event is written in the same transaction as the state change, which is the outbox: the fact and the change commit together or not at all.
24–26
A plain integer, deliberately, not a `ForeignKey`. The database-level link would be billing reading orders' tables, which is exactly the coupling the boundary is drawn to prevent.
29–36
Deduplication is the consumer's job under at-least-once delivery. Claiming the id inside the same transaction as the work makes a redelivery harmless.

Why this works: Each context owns its model and its rules, the boundary is crossed by a fact rather than an import, and a redelivered event cannot produce a second invoice.

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

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

Together

python
# DDD draws the line; the event crosses it.
# orders/ (context)   publishes  order.placed
# billing/ (context)  subscribes, and keeps its OWN model of an order

Remember: 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.

See also: monolith modular monolith and microservices · layered clean and hexagonal · producers consumers queues and topics · eventual consistency outbox and versioning

Advertisement