Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/four-architectures-in-one-solution && read --section="top" 0%
Architecture

Four Architectures in One Solution

Inflow is a modular monolith whose modules agree on their boundaries and disagree about everything inside them - a four-project onion next to a single feature-sliced assembly. Part 1 of a series reading the money-moving half of the estate.

By Kumar Chandrachooda 07 Jan 2026 7 min read
One solution, five modules, four different internal shapes

Most modular monoliths I have read are monotonous by design. One module gets built, the pattern is agreed, and the next four are copies with the nouns swapped — same folders, same project split, same repository base class. That consistency is usually sold as a feature, and it usually is one. It also means that after you have read the first module you have read them all, and the architecture stops being a subject.

Inflow does the opposite, on purpose. It is a five-module .NET 6 modular monolith — Users, Customers, Payments, Wallets and a Saga host — where each module is treated as an independent vertical slice free to pick its own internal architecture. The author used the permission. Wallets is a four-project onion whose domain assembly cannot see Entity Framework. Payments is one project with feature folders and a DbContext injected straight into query handlers. Customers and Users are a middle setting. Saga is eight files wrapped around a third-party library. Five modules, four internal architectures, one solution file.

To be clear up front: I did not write Inflow. It is MIT-licensed teaching code, © 2021 DevMentors, written by Piotr Gankiewicz (github.com/devmentors/Inflow), and it is the companion repository for their Building Modular Monolith course. I am a source-reader here, not a maintainer. Where the implementation is the story I quote it, attributed and from branch master; everything else is fresh example code written for the article. This series is deliberately narrow: it reads Wallets and Payments closely, and reaches into Customers, Users and Saga only where the seams between them are the subject.

The seam every module agrees on

Before the disagreement, the agreement, because the disagreement only reads as a choice against it.

Every module implements one small interface and nothing else is required of it:

internal class WalletsModule : IModule
{
    public string Name { get; } = "Wallets";

    public IEnumerable<string> Policies { get; } = new[] { "transfers", "wallets" };

    public void Register(IServiceCollection services)
    {
        services.AddCore();
        services.AddApplication();
        services.AddInfrastructure();
    }

    public void Use(IApplicationBuilder app)
    {
        app.UseContracts()
            .Register<CustomerCompletedContract>()
            .Register<CustomerVerifiedContract>();
    }
}

(src\Modules\Wallets\Inflow.Modules.Wallets.Api\WalletsModule.cs, branch master.)

A name, a set of authorization policy names, a registration hook and a startup hook. There are no project references between modules anywhere in the solution — Wallets cannot see a Payments type at compile time, and vice versa. They talk over an in-process message broker, publishing records that the other side has re-declared in its own namespace. Each module owns a PostgreSQL schema (users, customers, payments, wallets) and there is not a single foreign key across them.

That is the boundary contract, and it is the same for all five. Everything inside it is left to the module. Compare PaymentsModule, the same interface, taking the offer:

internal class PaymentsModule : IModule
{
    public string Name { get; } = "Payments";

    public IEnumerable<string> Policies { get; } = new[] { "deposits", "withdrawals" };

    public void Register(IServiceCollection services) => services.AddCore();

    public void Use(IApplicationBuilder app)
    {
    }
}

One registration call instead of three, and an empty Use. The shape of the module has already diverged in the file whose job is to make modules interchangeable.

The four shapes

Wallets is the onion, enforced by the compiler. Four production projects — Api, Infrastructure, Application, Core — in a strict chain, plus three test projects, the only tests in the estate. The whole architectural claim is one line of XML:

<!-- Inflow.Modules.Wallets.Core.csproj -->
<ItemGroup>
  <ProjectReference Include="..\..\..\Shared\Inflow.Shared.Abstractions\Inflow.Shared.Abstractions.csproj" />
</ItemGroup>

That is the only project reference Wallets.Core has. Inflow.Shared.Abstractions contains no EF Core, no ASP.NET, no DI container beyond the abstractions package. You cannot write _dbContext inside Wallet.cs, because the type does not resolve. The ban is absolute, permanent, and checked on every future edit by the build — which is a considerably stronger guarantee than a review convention.

Payments is the feature slice, enforced by nothing. Three production projects, but the fat one holds domain entities, command handlers, EF configurations, migrations, repositories and read models together, organised by feature: Deposits/, Withdrawals/, and a shared DAL/. Its project file declines the constraint Wallets accepted:

<!-- Inflow.Modules.Payments.Core.csproj -->
<ItemGroup>
  <ProjectReference Include="..\..\..\Shared\Inflow.Shared.Infrastructure\Inflow.Shared.Infrastructure.csproj" />
  <ProjectReference Include="..\Inflow.Modules.Payments.Shared\Inflow.Modules.Payments.Shared.csproj" />
</ItemGroup>

Shared.Infrastructure drags in EF Core, Npgsql, ASP.NET authentication and the outbox implementation, and the Payments domain entities compile against all of it. The consequence is not hypothetical: BrowseDepositsHandler takes PaymentsDbContext as a constructor parameter and writes its projection inline. Worth being precise about the criticism, though — Payments has not abandoned layering, it has abandoned enforcement. Open Core\Deposits\Domain\ and you find Entities/, Factories/, Repositories/, Services/. The onion is present as folders. Only the compiler has been told to stop caring.

Customers and Users are the middle setting: Api plus Core, two projects each, domain and application and persistence together but the HTTP surface held separately. This is the shape most teams actually ship, and it is the one the estate spends the least effort explaining.

Saga is barely a module at all. One project, eight C# files, a PackageReference to Chronicle 3.2.1 and a single services.AddChronicle(). It holds one saga class, four re-declared message records and an event handler that forwards everything to a saga coordinator. It exists to demonstrate that a cross-module workflow can live outside the modules it coordinates.

Why the contrast is worth 15,000 words

Because the estate never says which one it thinks is better, and reading both closely is the only way to find out.

The repository has no ADR directory, no per-module README, and — across roughly 190 files in these two modules — seven distinct intent comments in total. Two of them are identical // This could be refactored to an application service with checksum validation etc. lines; two more are identical // For PostgreSQL UseXminAsConcurrencyToken() can be used instead lines. Nothing anywhere states that Wallets and Payments are a deliberate contrast. The README's line about each module being “an independent vertical slice with its custom architecture” is the closest thing to a recorded rationale, and it is a permission, not a verdict.

So the verdict has to be reconstructed from the code, and that is what this series does. The short version, which the next fourteen parts earn: the layering buys exactly one thing, that thing is real, and every defect in this estate that would actually cost you money lives somewhere else entirely — in a value object's range check, in a JSON string built by hand in one module and parsed by a deserialiser in another, in a version counter that increments once per object lifetime.

And to set the tone for the criticism ahead: this is a free teaching repository with no deployment, no CI and no hosted instance, last touched in 2022. Several of its decisions are genuinely clever, and I will say so before I complicate them — Wallet.CurrentAmount() obtains event sourcing's best property inside a plain relational model, and Wallet.TransferFunds breaks the aggregate rule for a defensible reason. Both get a full part, and both get their defence first.

The shared mini-framework these modules sit on — the message broker, the dispatchers, the outbox, the contract registry — is a separate subject with its own series, The Framework Underneath. Two more companion series cover the microservices branch (One Module Leaves the Process) and the repository read as an artefact (The Repo Is the Lesson). This one stays inside the modules.

Where the series goes

  1. Four architectures in one solution — this post.
  2. The wallet that adds itself up — a balance that is a fold over its own ledger, and why that is the best idea in the module.
  3. A million is where the wallet breaks — the range check that makes a legal wallet permanently unreadable.
  4. Two aggregates, one method — a deliberate DDD violation, defended, then complicated.
  5. The reference that points at itself — the transfer correlation id that cannot correlate.
  6. A setter on the ledger — the one place persistence deforms the domain model.
  7. The version that counts to two — a concurrency token that works and a version number that lies.
  8. Money without a currencyAmount, Currency, numeric, and nothing that rounds.
  9. Onion versus folder — what the four projects actually buy, and the one claim that does not survive.
  10. The read side the layers lost — where the two modules genuinely diverge, and which one wins.
  11. One Guid, five customers — a single identity with five representations and five refresh disciplines.
  12. Lock the customer, half the estate notices — two dead methods on a live entity.
  13. A saga held together by a string — hand-written JSON on one side, a deserialiser on the other.
  14. Compensation that cannot fire — a saga over fire-and-forget messaging.
  15. The tests that document the bug — the honest retrospective.

If you have ever argued about whether a domain project deserves its own assembly, this estate settles the argument empirically by containing both answers. We start at the best thing in it: a wallet with no balance column, in the wallet that adds itself up.