Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/onion-versus-folder && read --section="top" 0%
Architecture

Onion Versus Folder

Wallets is four projects, Payments is one, and the same author wrote both in the same week. Three claims are usually made for the extra assemblies - one is true and verifiable in XML, one is weaker than it looks, and one is provably false.

By Kumar Chandrachooda 13 Jan 2026 6 min read
Concentric rings on one side, a stack of folders on the other

Every argument I have sat through about whether the domain deserves its own assembly ends the same way: two experienced people, two coherent positions, no evidence. One says the compiler should enforce the dependency direction. The other says four projects is ceremony and folders express the same intent at a tenth of the cost. Both are right about their own experience, and neither has the counterfactual, because nobody builds the same system twice.

Inflow does. Part 8 finished the domain model; this part reads the two architectures against each other. Same author, same week, same shared framework, same problem domain — money moving through accounts — and two answers. That makes the usual claims checkable instead of arguable.

The evidence, in XML

Wallets is four production projects in a strict chain: ApiInfrastructureApplicationCore, with Core at the centre. Payments is three, and the one that matters holds domain entities, command handlers, EF configurations, migrations, repositories and read models together in feature folders.

The entire architectural difference is two project files.

<!-- Inflow.Modules.Wallets.Core.csproj -->
<ItemGroup>
  <ProjectReference Include="..\..\..\Shared\Inflow.Shared.Abstractions\Inflow.Shared.Abstractions.csproj" />
</ItemGroup>
<!-- 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.Abstractions is interfaces, value objects and marker types — no EF Core, no ASP.NET, no container beyond the DI abstractions package. Shared.Infrastructure is the implementation of all of it.

Now the three claims.

Claim A — “layers give you a domain free of infrastructure”. True.

You cannot write _dbContext inside Wallet.cs. The type does not resolve; the build fails; there is no discussion, no reviewer, no convention to remember. That guarantee holds for every future edit by every future contributor, and it is worth stating as a strength rather than a triviality — a compiler-enforced ban is a stronger control than any process, and this is the only one in the estate.

The cost of declining it is visible on the other side and is very small: two files. BrowseDepositsHandler and BrowseWithdrawalsHandler take PaymentsDbContext as a constructor parameter. That is the entire realised consequence of Payments' choice. Not a leaky domain, not entities with DbSet properties — two query handlers that reached for the context because it was in scope.

And it is worth being precise about what Payments did not do. Open Payments.Core\Deposits\Domain\ and you find Entities/, Factories/, Repositories/, Services/. The onion is there as folders. Interfaces are declared in Domain/Repositories/ and implemented over in DAL/Repositories/, which is the dependency-inversion shape drawn correctly on a whiteboard and then not enforced. The difference between these modules is not “layered versus not”. It is “layers enforced by the compiler versus layers enforced by convention”. That is a much narrower disagreement than the project count suggests.

Claim B — “layers give you a substitutable read side”. True, and weaker than it sounds.

Wallets declares its read side as an abstraction in the Application layer:

internal interface IWalletStorage
{
    Task<Wallet> FindAsync(Expression<Func<Wallet, bool>> expression);
    Task<Paged<Wallet>> BrowseAsync(Expression<Func<Wallet, bool>> expression, IPagedQuery query);
}

The parameter type is the tell. Expression<Func<T, bool>> is a LINQ-provider-shaped contract — the only things that can implement it usefully are ORMs. It abstracts which ORM, not whether. And to make x => true composable with optional filters the module carries a hand-rolled expression combinator with a custom ExpressionVisitor in Application\Wallets\Storage\Extensions.cs, machinery Payments does not need because it composes IQueryable directly.

This claim gets its own article, because the trade turns out to run the other way. Part 10 is where it lands.

Claim C — “layers give you testability”. False, and it is the sharpest thing in the comparison.

The correlation looks perfect: Wallets is the only module with tests — three test projects — and Wallets is the layered one. It is also entirely spurious.

Here is the unit test's arrangement, in full:

_walletRepository = Substitute.For<IWalletRepository>();
_logger = Substitute.For<ILogger<AddFundsHandler>>();
_clock = Substitute.For<IClock>();
_messageBroker = Substitute.For<IMessageBroker>();
_handler = new AddFundsHandler(_walletRepository, _clock, _messageBroker, _logger);

(Wallets.Tests.Unit\Commands\AddFundsHandlerTests.cs.)

Four NSubstitute doubles, four interfaces, one constructor call. Now the Payments equivalent, which has no test:

public StartDepositHandler(ICustomerRepository customerRepository, IDepositRepository depositRepository,
    IDepositAccountRepository depositAccountRepository, IClock clock, IMessageBroker messageBroker,
    ILogger<StartDepositHandler> logger)

Six constructor parameters, every single one an interface. The identical test could be written against it this afternoon. Nothing about the assembly boundary is what makes AddFundsHandler testable — constructor injection and interface-typed dependencies are, and both modules have them in equal measure. Payments simply has no test project. Its Extensions.cs even names two: InternalsVisibleTo("Inflow.Modules.Payments.Tests.Integration") and ...Tests.Unit, neither of which exists in the repository. The intent to test Payments is recorded in the source and was never carried out, and that absence is what the correlation is actually measuring.

There is one test the layering does uniquely enable, and it should be credited. Wallets.Tests.Unit\Entities\WalletTests references Wallets.Core and Wallets.Application and exercises Wallet with no doubles at all — a pure domain test whose project graph contains no ORM. The Payments equivalent would drag Shared.Infrastructure, EF Core and Npgsql into the test assembly to instantiate a Deposit. That is a real difference, and it is worth exactly the two facts that were written against it.

What the seams cost

Counted honestly, on master:

Cost Wallets Payments
Production projects 4 3
Extensions.cs registration files 4 (two of them no-ops) 1
InternalsVisibleTo attributes 21 4
Named assemblies that do not exist 1 (Tests.Contract, in three files) 2
Projects to open to read one feature 4 1

The no-ops are literal. Wallets.Core\Extensions.cs is public static IServiceCollection AddCore(this IServiceCollection services) { return services; }, and Application\Extensions.cs is the same with a different name. WalletsModule.Register calls all three in sequence to preserve the symmetry; PaymentsModule.Register is services.AddCore(); and that one call registers nine services, Postgres, the outbox and the unit of work.

The last row is the one I feel most when reading. “How does a deposit work” in Payments is Core\Deposits\ — command, handler, entity, repository, DTO, events, one folder. “How does a transfer work” in Wallets is a controller in Api, a record and a handler in Application, an aggregate in Core, and a repository and a DbContext in Infrastructure. Four .csproj files and a lot of navigating for a feature that is, in both cases, roughly a hundred lines.

What actually crosses the boundary

The deflating finding is what happens when you tabulate the seams a request passes through rather than the assemblies it passes through. HTTP to module: an internal record command in both. Dispatcher to handler: identical machinery in both. Handler to domain: an entity plus Amount in both. Domain to storage: an interface in both, declared in a different project in one case and a different folder in the other. Module to module: IMessageBroker.PublishAsync on a locally-declared record, identically.

Nine of the eleven boundaries a request crosses are the same in both modules. The layering changes which assembly a type lives in, not what crosses. The two that genuinely differ are the read path and the compile-time ban, and one of those is the subject of the next part.

The conclusion I would defend: the four-layer split is a real, permanent, single-purpose constraint that costs about twenty files of ceremony — and every defect in this estate that would actually hurt you is orthogonal to it. The uncorrelatable transfer legs, the range check on the running total, the version latch, the missing transactions, the magic-string saga key: all of them are equally available to a one-project module and a four-project one. Choose the layering if you want the ban; do not expect it to buy you correctness, and do not let anyone tell you it buys you tests.

Next, the one place the two architectures produce genuinely different code — and the layered module does not win it: the read side the layers lost.