Overview
Encapsulated Modular Architecture is an opinionated approach to building modular monoliths using a combination of patterns and governance that have a proven record of success. It was developed by NimblePros and Steve “Ardalis” Smith and has been used successfully by the authors individually as well as by several of their clients. When applied properly, it helps to ensure modules are properly encapsulated and independent. This in turn reduces the “blast radius” of changes made to each module and enables greater maintainability and system stability as well as more opportunities for parallel development of the application.
Goals
EMA is a specific implementation of the modular monolith architecture and as such it has the same goals and trade offs. Specifically, it seeks to use encapsulation and modularization to break large applications up into smaller cohesive pieces that can be reasoned about in isolation as separate bounded contexts.
Core Principles
EMA follows these core principles.
Encapsulation Like You Mean It
Most of the other rules flow from this one. The inner workings of each module should be encapsulated, meaning they should be hidden from the application itself and every other module. It should be possible to completely change the implementation of a module, swap out its data store for an entirely different technology stack, and as long as its public contracts are upheld, the rest of the application should be unaffected and unaware of the change.
Module Implementation Projects are Internal
Use the C# internal keyword liberally within the module implementation project. This provides compiler protection against other projects being able to use these types. You can allow the test project for the module access using InternalsVisibleTo but no other projects should have insight into this project. The module’s bootstrapping extension method (see below) is a rare exception and should be public.
Minimize References to Module Implementation Projects
Typically the only references to a module implementation project should come from its test project(s) and the application entrypoint / composition root. Use analysis tools to enforce this during continuous integration and/or at build time. Reject changes that would add project references to module implementation projects from non-approved sources.
Explicit Contract Projects
See the named Explicit Contract Projects pattern. Every module implementation project should depend on a public Contracts project. The implementation project should implement services and/or handlers that use the types defined in the Contracts project.
Modules Own Their Data
Each module should have its own data store and its own means of connecting to that data store, independent of every other module. It’s possible to use a single database server or even separate schema within the same logical database, as long as the method of access isolates the connection to only the module’s schema elements.
Modules Own Their Bootstrapping
Every module exposes a public method that is used at application start to wire up that module and its dependencies. In C# this is typically done with a static extension method with a name like AddMODULENAME(). In this method, the module should include any registrations it needs to make for dependency injection, configure database connections and configuration, and all of the things an application would normally do in its startup that are specific to that module.
An example from the Riverbooks Sample App:
// in the Books module
public static class BookModuleServiceExtensions
{
public static IServiceCollection AddBookModuleServices(this IServiceCollection services,
ConfigurationManager config,
ILogger logger,
List<System.Reflection.Assembly> moduleAssemblies)
{
string? connectionString = config.GetConnectionString("BooksConnectionString");
services.AddDbContext<BookDbContext>(config =>
config.UseSqlServer(connectionString));
services.AddScoped<IBookRepository, EfBookRepository>();
services.AddScoped<IBookService, BookService>();
// if using Mediator in this module, add any assemblies that contain handlers to the list
moduleAssemblies.Add(typeof(BookModuleServiceExtensions).Assembly);
logger.Information("{Module} module services registered", "Books");
return services;
}
}
In this example, the bootstrapping AddBookModuleServices() method configures the connection to its database, wires up a couple of services it uses internally, and adds its assembly to a list used for scanning for mediator handlers. Finally, it logs that its services were registered, so when the app starts up, it’s clear from the log messages that each module has been loaded properly.
Keep the Host Small
The “Host” is the entrypoint application. Whether that’s a CLI console app, an ASP.NET Core web app, or a native WinForms or WPF app, you want to minimize how much logic lives in the application UI project itself. The more you can push into the modules, the more independent they become.
Modules Own Their UI
Wherever possible, keep the UI a module needs in the module. If it’s web API endpoints, define the endpoints in the module implementation project. If it’s Razor Pages, put the pages in the module implementation and make it a razor class library so it can be loaded by the host. If it’s WinForms, put the forms in the module implementation.
Module Structure
One of the benefits of using a modular approach is that any given module can use its own internal structure. A common structure that works well for more complex modules that leverage DDD domain models and pipeline behaviors with middleware looks like this (for a web API project):
Module Implementation Project/
├── Data/
├── Domain/
├── Endpoints/
├── Integrations/
├── Interfaces/
├── Services/
└── UseCases/
Data is responsible for data access. It would hold DbContext or other data access types, including Repository implementations, if any.
Domain is the domain model. It should contain entities, aggregates, value objects, specifications, domain events, etc.
Endpoints is where the API endpoints live. The host application should discover them within this project.
Integrations is where implementation of this module’s own Contracts project types live. Handlers and services with abstractions defined in Contracts have their implementation here to make it clear that if these types change, they can break this module’s public interface.
Interfaces holds internal interface definitions used within the module.
Services (optional) holds implementations of internal interfaces (that don’t belong elsewhere), if any.
UseCases holds message types (queries, commands, events) and handlers for internal operations.
Ideally and for consistency, every operation the module performs should go through the module’s internal UseCases. Following this rule ensures that all of the middleware behavior defined for the module’s pipeline will be used for every operation performed within the module.
In practice, this would mean that every service or handler in Integrations simply translated a public contract message into a module internal message and then delegated to the internal service or handler. This is ideal but also can result in some tedious code in the Integrations folder, so some teams choose to just put the actual logic in the handlers/services in Integrations.
A best case scenario would be to leverage code generation to eliminate the need for the handwritten Integrations logic that simply acts as an adapter for the internal processes.
Communication Between Modules
Modules communicate with one another using contracts. Contract types may include a combination of message types (commands, queries, events), DTOs, and service interfaces.
Messages: If a mediator library is in use, modules can send messages back and forth using the message types and DTOs defined in another module’s Contracts project.
For example:
// query
Orders.Contracts.OrderDto order = await _mediator.Send(new OrderQuery(orderId));
// command
var result = await _mediator.Send(new CreateAccountCommand(accountDetailsDto));
// (integration) events
var orderCreatedEvent = new Orders.Contracts.OrderCreatedEvent()
{
// details omitted
}
await _mediator.Publish(orderCreatedEvent); // other modules can handle the event
DTOs: As seen in the examples above and below, since internal module types cannot be used, public data transfer types must be defined as needed for any parameters or return types of messages or services. DTOs should follow standard DTO best practices.
Service Interfaces: Instead of using messages, modules can simply define services that other modules instantiate via dependency injection.
Enforcing Boundaries
TBD
Patterns Used
EMA combines the following patterns in specific ways: