Structuring DjangoPlay as a Domain-Driven Monolith, Not a Microservice Sprawl
How independent domain apps, a one-way dependency rule, and boundary-only DTOs keep a growing Django codebase organized without splitting into services.
CB### 1. Why This Stayed a Monolith
It's tempting to reach for microservices the moment a codebase has more than one "domain" in it โ a service per app, a database per service, a network call where a function call used to be. For DjangoPlay, that trade wasn't worth it. One deploy, one database, one set of migrations is simply less to operate for a product still finding its shape. The part that actually matters โ keeping unrelated concerns from tangling into each other โ doesn't require a network boundary. It requires discipline about who's allowed to import what.
That's what domain-driven design gives a monolith: the organizational benefit of separate services, without the operational cost of actually running separate services.
2. A Domain App Is a Bounded Context
DjangoPlay is split into apps like users/teamcentral (identity and HR), locations/industries (reference data), entities, finance, and aicore โ each one a Django app, and each one a bounded context in the DDD sense. A domain app owns its own models, its own services/ layer for business logic, and its own API surface. Nothing outside finance reaches into a finance model directly; it goes through finance's services.
That's the whole trick, really. Microservices enforce this boundary with a network call. A monolith has to enforce it with convention and code review โ which is exactly why the next rule exists.
3. One-Way Dependencies: Domain Apps Lean on a Shared Foundation, Never the Reverse
A handful of apps sit underneath everything else: core (shared infrastructure and execution context), audit (change history), and policyengine (authorization). Every domain app is free to depend on these. None of these foundation apps is allowed to import from a domain app.
Client request
โ
Domain app (finance, entities, aicore, โฆ)
โ โ
PolicyEngine Audit
(is this action allowed?) (should this change be recorded?)
โ โ
Core โ ORM models, PostgreSQLThat single arrow direction is what keeps 15+ apps from turning into a dependency tangle as the platform grows. If you're ever tempted to have core know about finance, that's the signal the logic belongs in finance's own service layer instead.
4. Audit Is Opt-In, Not Injected
audit doesn't reach into every app and decide what's worth tracking โ that would make it a hidden dependency in the wrong direction. Instead, each domain app registers which of its own models should be audited, in its own apps.py. audit just watches the registry; it never assumes.
# finance/apps.py
def ready(self):
from audit.lifecycle.registry import AUDIT_TRACKED_MODELS
AUDIT_TRACKED_MODELS.update({
"finance.Invoice",
"finance.Payment",
"finance.CreditNote",
# finance.DocumentSequence deliberately excluded โ
# it's an internal counter, not a user-meaningful record.
})Small pattern, but it says something bigger: a domain app decides what about itself is worth recording. The foundation app just provides the mechanism.
5. Boundaries Get a DTO. Nothing Else Does.
It's easy to over-engineer this and wrap every model in a data-transfer object "for consistency." DjangoPlay does the opposite: a DTO gets created only where a workflow genuinely crosses a real boundary โ API into service, or service into a calculation step โ and never as a blanket rule per model. Fewer objects to maintain, and the ones that do exist are there because something actually needed them.
The same restraint shows up in how a domain app owns its own state changes. In finance, nothing sets an invoice's status directly โ every transition goes through one state machine that's the single place that knows which transitions are even legal. One writer, one set of rules, no way for a status to end up somewhere it shouldn't.
For the full architecture reference โ every layer, every convention, and the reasoning behind each one โ see the architecture-style documentation โ.
Takeaways
- A monolith can have real boundaries. You don't need a network call to enforce "this app doesn't touch that app's models" โ you need a convention everyone actually follows.
- Pick one dependency direction and never break it. Domain apps depend on the foundation; the foundation never depends on a domain app.
- Let each domain decide what's worth tracking about itself. A shared mechanism (audit, policy checks) is more durable than a shared app trying to guess every domain's rules for it.