ADR-0001: Monolithic Modular Architecture¶
Status¶
Accepted
Context¶
We needed to choose an architecture for the Spryx Backend that would:
- Support rapid development - Small team, need to move fast
- Allow future scaling - May need to extract services later
- Maintain code quality - Clear boundaries and testability
- Enable team growth - Easy for new developers to understand
The main options were: - Microservices from the start - Traditional monolith - Monolithic modular architecture
Decision¶
We chose Monolithic Modular Architecture with Clean Architecture principles.
This means: - Single deployable application - Code organized into well-defined modules - Modules communicate through defined interfaces - Each module could be extracted to a service if needed
Module Structure¶
src/app/modules/
├── rag/ # RAG module
├── billing/ # Billing module (future)
└── agent/ # Agent module (future)
Key Principles¶
- Modules are bounded contexts - Each module has its own domain models
- Clean Architecture layers - Domain → Application → Infrastructure
- Dependency inversion - Application depends on ports, not implementations
- Vertical slices - Features organized by domain, not layer
Consequences¶
Positive¶
- Simpler deployment
- Single application to deploy
- No service discovery needed
-
Easier debugging and tracing
-
Faster development
- No network overhead between modules
- Refactoring is a code change, not distributed system change
-
IDE support for cross-module navigation
-
Lower operational complexity
- One database (with logical separation)
- One deployment pipeline
-
One monitoring setup
-
Future extraction possible
- Module boundaries = potential service boundaries
- Ports enable swapping implementations
- Communication patterns already defined
Negative¶
- Scaling constraints
- All modules scale together
-
Can't independently scale hot modules
-
Technology lock-in
- All modules use Python
-
Can't use best-fit language per module
-
Single point of failure
- One app down = everything down
-
Requires robust error handling
-
Discipline required
- Module boundaries must be enforced manually
- Easy to accidentally cross boundaries
Neutral¶
- Testing approach
- Can use unit tests for domain/application
- Integration tests need single database
-
No distributed system testing complexity
-
Database strategy
- Shared database with separate tables per module
- Could migrate to separate schemas/databases later
Alternatives Considered¶
Microservices from Start¶
Why not chosen: - Premature optimization - Too much operational complexity for small team - Network overhead for every inter-service call - Harder to refactor domain boundaries - Distributed transactions complexity
Traditional Monolith (no module structure)¶
Why not chosen: - No clear boundaries - Hard to maintain as codebase grows - Difficult to extract later - Coupling between features
Serverless Functions¶
Why not chosen: - Cold start latency issues - Complex state management - Vendor lock-in - Debugging difficulty
Implementation Notes¶
Module Communication¶
Within the monolith, modules communicate through: - Ports (interfaces) for synchronous operations - Shared types for IDs and common exceptions
# Module A defines a port
class DocumentLookupPort(Protocol):
async def get_document_title(self, doc_id: str) -> str | None: ...
# Module B implements it
class DocumentLookupAdapter:
async def get_document_title(self, doc_id: str) -> str | None:
doc = await self.doc_repo.get_by_id(doc_id)
return doc.title if doc else None
Future Extraction¶
When ready to extract a module: 1. Replace in-process port calls with HTTP/gRPC clients 2. Split database tables to separate database 3. Deploy module as separate service 4. Update service discovery
Enforcement¶
Module boundaries are enforced through: - Code review (checklist) - Import conventions (absolute paths) - Directory structure - Documentation