Architecture Overview
This page connects the modular monolith philosophy to the concrete structure of generated projects.
Project Structure
A generated project follows this high-level structure:
my_project/
├── apps/ # Frontend applications (Turborepo workspaces)
│ ├── landing/ # Astro static site
│ └── my_project/ # Vite + React SPA
├── packages/ # Shared frontend packages
│ ├── ui/ # Shared React components
│ ├── eslint-config/ # Shared ESLint config
│ └── typescript-config/ # Shared TypeScript configs
├── config/ # Django settings and configuration
│ └── settings/ # Environment-specific settings
├── my_project/ # Django modular monolith container
│ ├── users/ # User domain module
│ └── [your modules]/ # Add domain modules here
└── docker/ # Docker configurations
The Modular Monolith Container
The {project_slug}/ directory is the heart of the modular monolith. Each subdirectory is a Django app representing a domain module:
my_project/
├── users/ # User management, authentication
├── billing/ # Payments, subscriptions (you add this)
├── notifications/ # Email, push notifications (you add this)
└── core/ # Core product domain (you add this)
Add new modules as sibling directories to users/. Code structured by domain concept helps new team members navigate and understand the project quickly.
Django Apps as Domain Modules
Each module is a standard Django app with:
models.py- Domain modelsviews.pyorapi/- HTTP interfacesservices.py- Business logic (recommended, see Service Layer Patterns)tests/- Module-specific tests
Modules should be cohesive: everything related to a domain concept lives together.
Domain Boundaries
How Modules Communicate
Modules should communicate through explicit interfaces, not by reaching into each other’s internals. The primary mechanism is domain events, an in-memory pub-sub system where modules publish events when significant things happen, and other modules subscribe to react.
The pattern works like this:
# Publishing an event (in a service)
def _publish_event():
event = OrderPlacedEvent(order_uuid=str(order.uuid), ...)
event_bus.publish(event)
transaction.on_commit(_publish_event) # Only publish after commit!
The critical detail is transaction.on_commit(): events are only published after the database transaction commits successfully. This prevents handlers from processing events for data that might roll back.
Handlers are registered during app startup in AppConfig.ready(), ensuring loose coupling between modules:
# In orders/apps.py
def ready(self):
from {project_slug}.domain_events.bus import event_bus
from {project_slug}.domain_events.events import PrescriptionRequestApprovedEvent
from {project_slug}.orders.handlers import handle_prescription_approved
event_bus.subscribe(PrescriptionRequestApprovedEvent, handle_prescription_approved)
This approach provides:
Decoupling: Publishers don’t know about subscribers
Testability: Modules can be tested in isolation
Scalability path: Swap the in-memory bus for RabbitMQ/SNS when needed
For simple model lifecycle hooks within a single module, Django signals remain appropriate. Domain events are preferred for cross-module communication.
See Event-Driven Architecture for implementation details, code examples, and guidance on when to use signals vs events.
Avoid:
Importing models directly from other modules
Accessing other modules’ internal functions
Shared mutable state
Enforcing boundaries:
Conventions aren’t enough. Boundaries erode without tooling. For enforcement strategies including import-linter contracts, architectural tests, and the no-FK database pattern, see Module Boundary Enforcement.
What Belongs in a Module
A module should contain everything needed to fulfill its domain responsibility:
Models representing domain entities
Business logic operating on those entities
APIs exposing functionality to other modules or external clients
Tests validating the module’s behavior
Cross-Cutting Concerns
Some concerns span modules:
Authentication/Authorization - Handled at middleware/decorator level
Logging - Configured at infrastructure level, used everywhere
Error Handling - Consistent patterns, centralized reporting
These live in shared infrastructure, not duplicated in each module.
Scaling Pathways
Adding New Modules
When your domain expands, add a new module:
Create a new directory under
{project_slug}/Structure it as a Django app
Register it in
INSTALLED_APPSDefine its public interface (services, APIs)
Keep dependencies explicit
With a modular monolith, teams can make changes across modules efficiently—it doesn’t take 5 PRs across 5 projects with deployment order dependencies to implement a feature.
Extracting Modules to Services
If a module needs independent scaling or team ownership:
The module already has a defined interface
Create a new service with that interface
Replace in-process calls with network calls
Deploy independently
This is straightforward because boundaries are already clear. Focus on making your app modular first, and you’ll be set up to migrate to microservices in the future if needed.
Horizontal Scaling the Monolith
Before extracting services, consider scaling the monolith:
Multiple application instances behind a load balancer
Database read replicas
Caching layers
Background job workers (Celery)
A well-structured monolith scales further than you might expect.
Supporting Research
This approach draws from several practitioners who’ve written about their experiences:
Dan Manges (Root Insurance): “Our code is structured by domain concept, which especially helps new team members navigate and understand the project.”
Dan Manges (Root Insurance): “The boundary between stateful and stateless logic helps them think about implementing some of their most complex business logic in pure Python, completely separated from Django.”
Further Reading
The Majestic Monolith — DHH on why small teams should embrace monoliths
The Modular Monolith: Rails Architecture — Dan Manges on structuring code by domain at Root Insurance
Modular Monolith: A Better Way to Build Software — ThoughtWorks on the modular monolith as a middle ground