Back to JetBrains

Modular Monoliths: Goldilocks Architecture? | Steve Smith (Ardalis) & Eric Fleming | .NET Day 2026

JetBrainsOctober 15, 202641m
In a Nutshell

Modular monoliths deliver "Goldilocks" architecture by enforcing domain boundaries and encapsulation within a single deployable unit, avoiding both spaghetti code and microservices complexity. Each module owns its UI, business logic, data store, and contracts, enabling independent team ownership while maintaining logical separation through internal visibility and separate contract projects. The approach lets teams extract individual modules as microservices later if needed, without the upfront cost of distributed systems.

AI-Generated Notes

These notes were generated by AI and may contain inaccuracies.

Steve Smith and Eric Fleming discuss modular monoliths, also referred to as "Goldilocks" architecture. The Goldilocks metaphor comes from the children's story about a girl who finds the middle option that is "just right" - neither too hot nor too cold, representing an ideal balance between extremes. The presentation focuses on engineering modular monoliths as a middle-ground architectural approach.

Additional learning resources are available at modularmonolith.com, containing articles, videos, and other materials. The first law of software engineering states that everything is a trade-off. Software engineering involves trade-offs with no single correct architecture for all scenarios.

A monolith is a unit deployed as a single process. The term derives from Greek "monos," meaning alone or one. A monolithic application publishes only one thing without managing multiple independent processes. Even with monolithic architectures, there may be at least two processes when considering a separate database alongside the application code.

Monolithic architectures are often viewed negatively, but the monolith itself is not the problem. The real issue is allowing the codebase to become a tangled mess known as the "big ball of mud." Many teams build continuously without careful design, resulting in this problematic structure.

Layered architecture is a common pattern that organizes code by technical concerns: user interface in one layer, business logic in another, and data access as a third. This structure works with rules stating that the top layer can only communicate with the layer immediately below it, preventing shortcuts or layer skipping. Lower layers should not communicate with upper layers.

While layered architecture provides structure, it is useful but not sufficient. Nothing prevents different parts of the application from communicating with each other across layers, leading to "spaghetti code" where everything communicates with everything else. This makes it difficult to separate components and identify essential features, increasing maintenance difficulty and testing complexity.

The goal is to achieve "ravioli" code - individually wrapped pieces where each unit can have its own implementation, patterns, and team responsibility. When software architecture incorporates true modularity, each independent module has encapsulation and integrates explicitly with other modules using defined patterns and interfaces.

Encapsulation provides only a general set of interfaces through which operations pass, preventing other modules from accessing internal implementation details. The approach focuses primarily on features rather than technical layers, aligning with domain-oriented design. This ensures domain rules for specific features are encapsulated within single units rather than scattered across multiple locations.

Business rules are often scattered across JavaScript code in browsers, controllers, endpoints, libraries, and stored procedures. This distribution makes changes difficult when modifications are needed, as developers must locate and update all instances correctly and coordinate releases properly.

Each unit should not merely be a library of classes containing logic. It should include a user interface, data access, and all components needed to form a complete slice. The architecture adds boundaries between features to create standardization, resulting in logically separate units that compose the application.

Teams often rush toward microservices when facing large, complex monolithic systems, believing the only way to break them up is to divide into separate applications. Microservices are a popular option that gained significant traction over the past decade, but they carry inherent risks.

An analysis framework plots software units and their coupling on the vertical axis against the number of deployed services on the horizontal axis. The far left represents deploying a single monolithic system, while the far right represents multiple services. The upper right quadrant represents a proper microservices architecture with complete independence. The lower right quadrant represents the "distributed ball of mud" - the worst position combining problems of both massive monoliths and distributed architectures.

It is easier to move along one axis at a time. Moving to microservices becomes simpler after first identifying domain boundaries. The recommended approach involves moving to a modular monolith architecture with logical units before attempting separate, physically isolated microservices.

The Goldilocks metaphor presents three architectural options: no units at all with random structure leading to chaos; microservices breaking everything into separate applications communicating asynchronously or over networks; or logical separation while still deploying as a single unit. The middle option represents the recommended compromise for many applications.

A typical modular monolith contains a minimal host application, such as an ASP.NET Core Web Application. Each module becomes another project that the host merges and loads. For the architecture to succeed, each module must be independent, logically separated, internally cohesive, bounded, and context-specific.

Independence means teams working on individual modules should make changes without coordination with other teams, provided they do not break public contracts. Internal changes must be independent without requiring coordination work between teams.

Each module requires its own namespace, folder structure, and domain model. Modules should not rely on shared types, as changes to shared models may break other modules using them. Modules should align with user stories, user journeys, or features rather than technical concerns like user interfaces or data layers.

Elements within a module should be interconnected and work together toward the same goal. If code within a module works toward a completely different purpose, it likely belongs to another module. Each module should be treated as its own bounded context, with boundaries preventing other units from tampering with internal data.

Modular architecture allows different teams to be responsible for different modules within a unified deployment, enabling large numbers of engineers to work harmoniously on the application without constantly hindering each other's work.

Each module must have its own data store. Shared databases represent a common anti-pattern in microservices that leads to distributed ball of mud complexity. In modular monolith architecture, each module should have its own data store, and modules can use different persistence strategies.

Each module can be organized according to its complexity level. Simple modules may require minimal internal organization and can use straightforward CRUD operations. Complex modules may benefit from different patterns like clean architecture. Modules do not need to follow identical organizational styles.

To achieve modularity, each module requires a separate project using the internal keyword in C# to make types inaccessible outside the project. Each module needs its own schema or database, and its own persistence strategy. Modules can use different databases - MongoDB for one, SQL Server for another, SQLite for a third.

Public contracts must be defined in a separate project so other modules can communicate through common types. Using a message-passing tool like Mediator provides advantages for middleware pipelines handling cross-cutting concerns such as logging and validation.

Contracts cannot be defined within module projects themselves. If Module A calls something in Module B, it creates a reference. Later, if Module B needs something from Module A, a circular dependency results. Separate contract projects allow any module to reference public contracts of other modules without creating circular dependencies.

While Mediator provides middleware pipeline benefits, regular service interfaces can be used instead. Module A can reference an interface defined in Module B's public contract project, with the implementation inside Module B, without requiring Mediator.

Using the Mediator approach, the shopping cart module might create a new order by sending a command defined in the orders module's public contracts project. The mediator handles the command, and the processor in the orders module handles the request.

A comparison exists between logically separate modules in a monolithic application versus microservices, with various trade-offs. The approach does not preclude using microservices - a modular monolith can have individual modules extracted as microservices later when appropriate, with other modules communicating through public contracts.

The "River Books" project demonstrates the modular monolith structure with five modules organized in solution folders. The web project is minimal, containing only Program.cs and middleware for initializing modules. Each module is responsible for its own configuration process. Module implementations are mostly internal, while public contract projects contain interfaces used for inter-module communication.

Rider provides structure view tools for exploring modular monoliths and understanding boundaries. The project dependency diagram shows dependencies at the solution level. Double-clicking boxes reveals individual projects and specific dependency directions. The tool demonstrates how user implementation references book contracts without accessing book implementation details.

Enabling coupling analysis in Rider shows where dependencies actually occur. In the example, the Users module references book details through a query in the Add Item to Cart handler. The Mediator pattern is used to request book title and price information needed to add items to the shopping cart.

The system uses a book details query through the mediator pattern, taking the response to populate basket item data. While MediatR is used here, it's not required—services and software interfaces can serve the same purpose. The implementation avoids direct database access to the book entity, relying instead on contracts. The internal Books entity remains confined to its module and is only accessed by the test project for testing purposes.

Rider's dependency diagram illustrates module boundaries clearly, with the "Find Uses" feature allowing deeper exploration of relationships. Solution filters enable focusing on specific modules when applications scale to dozens of units with multiple teams. For example, when working on Books and Users modules, all unrelated projects can be unloaded, keeping only necessary contracts from other modules while excluding their implementations entirely.

Solution filters create .slnf files that can be committed to version control, allowing teams to maintain multiple filter configurations. A filtered solution might reference 16 projects while excluding 9 others. The reporting module can be completely excluded when not needed. Multiple solution windows can be open simultaneously—one filtered for focused work, another with the full solution for comprehensive testing. The CLI can also run Aspire with specific filter configurations.

Monoliths often lack sufficient logical separation, resulting in spaghetti code. Layers provide a starting point but aren't sufficient. Microservices may be overrated for many scenarios. The modular monolith approach represents the "Goldilocks" solution—balanced between the two extremes. Additional resources are available at riverbooks.ardalis.com, including documentation and GitHub links. Training courses on modular monoliths are offered through the "Don't Train" website with a 20% discount using code "Ardalis." NimblePros provides training and consulting for architecture transitions.

Modular architecture works best with a monorepo where all teams commit to the same Git repository. Continuous integration and deployment are essential prerequisites, requiring feature flags to allow incomplete work to be committed safely. Automated testing must catch issues before production deployment. The approach requires organizational maturity in both development practices and DevOps processes. However, benefits can be realized even with single-person teams focused on better code organization.

When one module needs to call another, contracts define the interface. If a required contract doesn't exist, coordination with the owning team is necessary—similar to microservice communication patterns. Module owners must understand and commit to the architectural boundaries for effective team segregation.

For atomic operations, transactions should remain within a single module. When eventual consistency is acceptable, inter-module communication becomes viable. Materialized View patterns allow modules to maintain their own data copies, improving both resilience and performance. When a dependent module becomes unavailable, the dependent module continues functioning using its local copy. Microservice patterns like outbox and asynchronous communication apply equally to modular monoliths. These patterns are covered in Domain-Driven Design and scope design courses.

Mediator implementations can be custom-built, use Jimmy Bogard's MediatR library (now commercial), or use the open-source "Mediator" library. Each option offers different features and trade-offs. The middleware pipeline capability allows standardized handling of validation, logging, exception handling, OpenTelemetry monitoring, and duration tracking across all requests—moving routine code from service methods into reusable middleware components.

Teams migrating from .NET Framework or moving to cloud environments often benefit from modular architecture. Direct microservices adoption has led to project failures in observed cases. Starting with modular monoliths allows modules to be extracted into microservices later if independent scaling becomes necessary. No modules have required extraction for performance reasons in recent NimblePros projects, though the capability exists for modest efficiency gains. Starting with modular monoliths avoids the rework required when microservice boundaries are incorrectly defined initially.

Each module can have its own database—a capability that has become significantly easier with modern tooling. Aspire enables running multiple databases in containers on development machines, removing the previous constraint of single organizational databases managed by dedicated teams. This represents a major improvement over conditions from 10-15 years ago.

Keep JetBrains in your library

Save the videos and channels worth coming back to, and find them again in one place.