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 models

  • views.py or api/ - HTTP interfaces

  • services.py - Business logic (recommended, see Service Layer Patterns)

  • tests/ - Module-specific tests

Modules should be cohesive: everything related to a domain concept lives together.

Shared Infrastructure

Cross-cutting concerns live outside the domain modules:

  • config/settings/ - Django configuration

  • docker/ - Container definitions

  • packages/ - Shared frontend code

This separation keeps domain modules focused on business logic while infrastructure concerns are handled consistently across the application.

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:

  1. Create a new directory under {project_slug}/

  2. Structure it as a Django app

  3. Register it in INSTALLED_APPS

  4. Define its public interface (services, APIs)

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

  1. The module already has a defined interface

  2. Create a new service with that interface

  3. Replace in-process calls with network calls

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