What layered, hexagonal and clean architecture really say, where they disagree, and how dependencies get inverted in C#, Java, Go and Rust, with .NET’s container lifetimes and captive dependencies shown in running code.
Every architecture diagram is an argument about one thing: which code is allowed to know about which other code. Get the direction right and you can swap a database, test without one, and change a framework without touching your business rules. Get it wrong and the rules are welded to a driver.
This part covers the three famous pictures (layered, ports and adapters, clean), the real disagreement between them that most articles flatten, and then the mechanics: how the four languages invert a dependency, and what a dependency injection container does and doesn’t do for you.
Try this first
An orders package holds your pricing rules and saves orders. Open the file. Does it import your database library?
Then answer these: if the answer is yes, what does a unit test of the pricing rule need to start? And when you swap PostgreSQL for a different store, which packages does the compiler rebuild?
The three pictures, and where they disagree
Ports and adapters came first, as a pattern write-up by Alistair Cockburn in 2005. Its intent, verbatim: “Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.”
The rule is about inside and outside, not up and down: “The asymmetry to exploit is not that between left and right sides of the application but between inside and outside of the application. The rule to obey is that code pertaining to the inside part should not leak into the outside part.”
He is blunt about why he didn’t draw layers, and the failure he’d seen too often: “The attempted solution, repeated in many organizations, is to create a new layer in the architecture, with the promise that this time, really and truly, no business logic will be put into the new layer. However, having no mechanism to detect when a violation of that promise occurs, the organization finds a few years later that the new layer is cluttered with business logic and the old problem has reappeared.”
And the hexagon means nothing numerical: “The hexagon is not a hexagon because the number six is important, but rather to allow the people doing the drawing to have room to insert ports and adapters as they need, not being constrained by a one-dimensional layered drawing.” His own port count is small: “two, three or four ports”.
Clean architecture came later, in Robert C. Martin’s 2012 post, and its rule is one sentence: “This rule says that source code dependencies can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle.”
Here’s the disagreement nobody mentions. Martin opens by grouping five architectures, including Cockburn’s, and says: “They all achieve this separation by dividing the software into layers.” Cockburn wrote his pattern, in his own words, “to get away from the one-dimensional layered picture and all that evokes”.
Both are defensible. They are not the same claim, and “hexagonal = onion = clean” flattens a real difference about what the picture is for. What they agree on is the mechanism, and Martin names it: “We usually resolve this apparent contradiction by using the Dependency Inversion Principle. In a language like Java, for example, we would arrange interfaces and inheritance relationships such that the source code dependencies oppose the flow of control at just the right points across the boundary.”
Cockburn does have a left and a right side, and he introduces it deliberately late: “The ports and adapters pattern is deliberately written pretending that all ports are fundamentally similar […] In implementation, ports and adapters show up in two flavors, which I’ll call primary and secondary […] They could be also called driving adapters and driven adapters.” A primary actor “drives the application”; a secondary one is the one “the application drives”. The test doubles differ accordingly: a script drives the primary side, and a fake stands in on the secondary side. Inside versus outside is the rule; driving versus driven is a drawing convention that follows from who starts the conversation.
Two more details from Martin worth keeping, because they’re the ones people drop:
- The circles are not a rule. “No, the circles are schematic. You may find that you need more than just these four. There’s no rule that says you must always have just these four. However, The Dependency Rule always applies.”
- Only simple data crosses. “Typically the data that crosses the boundaries is simple data structures. […] We don’t want to cheat and pass Entities or Database rows.”
Measured: which way do the arrows point?
Import graphs reported by go list in checks/part13_wiring/run.py. Go interfaces are satisfied implicitly, so the adapter needs no import of the rules at all; in C# or Java the adapter project would reference the domain, and the arrow would point inwards.
Our lab has two tiny Go modules that do the same job. In the layered one, the orders package holds the rules and talks to the database itself. In the ports-and-adapters one, orders declares the interface it needs, and a postgres package implements it:
// Package orders is the business logic in a ports-and-adapters design. It defines the
// port it needs and imports nothing from the outside world.
package orders
import "fmt"
// Store is the port: the orders package owns this interface, not the database package.
type Store interface {
Save(customer string, amountCents int64) (string, error)
}
type Service struct{ Store Store }
func (s Service) Place(customer string, amountCents int64) (string, error) {
if amountCents <= 0 {
return "", fmt.Errorf("an order must cost something")
}
return s.Store.Save(customer, amountCents)
}
What go list says about each design, counting only the imports that matter here (each package also imports fmt):
| Design | Imports | orders reaches the database |
Packages in orders‘ dependency tree |
|---|---|---|---|
| Layered | cmd/app → orders; orders → database/sql |
yes | 68 |
| Ports and adapters | cmd/app → orders, postgres; orders → nothing; postgres → database/sql |
no | 62 |
Look at what disappeared: orders imports nothing but fmt. Control still flows from orders into the database, but the source dependency is gone, because the adapter satisfies an interface the rules declare. In Go there is no arrow in either direction, since interfaces are satisfied implicitly. In C# and Java, the adapter project references the domain project, so the arrow literally points inwards. Either way it’s the Dependency Inversion Principle from Part 7, applied at a package boundary.
Go makes this cheaper than most languages, because interfaces are satisfied implicitly. The Go project’s review guidance states the rule as a matter of file placement: “Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values.”
Two cautions from Google’s Go style guide, which are easy to skip past:
- The adage is about return values: “Functions should take interfaces as arguments but return concrete types.” With an exception the adage drops: “Sometimes returning an interface is acceptable for encapsulation (e.g., error interface)”.
- And the common failure: “The most common mistake is creating an interface before a real need exists. Don’t confuse the concept with the keyword: Just because you are designing a ‘service’ or a ‘repository’ or similar pattern doesn’t mean you need a named interface type.”
Worth knowing that Go’s own documentation disagrees with itself here. Effective Go, written in 2009 and carrying the banner “This document was written for Go’s release in 2009 and is not actively updated”, says “the constructor should return an interface value rather than the implementing type”. Current guidance says the opposite. Idiomatic is dated, not eternal.
What the port buys you: the test
The point of the arrow isn’t tidiness. It’s what a test needs to run:
package orders
import "testing"
// The test double is three lines, and the test needs no database package at all.
type fakeStore struct{ saved int }
func (f *fakeStore) Save(customer string, amountCents int64) (string, error) {
f.saved++
return "saved " + customer, nil
}
func TestPlaceUsesTheStore(t *testing.T) {
store := &fakeStore{}
line, err := Service{Store: store}.Place("ada", 1999)
if err != nil || store.saved != 1 || line != "saved ada" {
t.Fatalf("got %q, %v, saved %d", line, err, store.saved)
}
}
No container, no mocking framework, no database. That’s Cockburn’s stated intent: “to be developed and tested in isolation from its eventual run-time devices and databases.”
It’s also where the two schools of testing part company. Martin Fowler’s names for them: “classical” (or Detroit) testers use real objects wherever they can and fake only what’s awkward, such as a database; “mockist” (or London) testers “will always use a mock for any object with interesting behavior”. Fowler’s own position in Mocks Aren’t Stubs: “Personally I’ve always been a old fashioned classic TDDer”, and “I’m still a convinced classicist” — while noting he hasn’t used mockist TDD “on anything more than toys”. Ports are useful to both schools, and neither justifies an IFooService for every class.
Functional core, imperative shell
A smaller version of the same idea fits inside a single package: keep the decisions pure and push the effects to the edge. Gary Bernhardt named it “functional core, imperative shell” in a 2012 screencast, and described the payoff there: “testing the functional pieces is very easy, and it often naturally allows isolated testing with no test doubles. It also leads to an imperative shell with few conditionals, making reasoning about the program’s state over time much easier.”
In practice: a function takes the data it needs and returns a decision, a value or an event. The caller does the I/O.
#[derive(Debug, PartialEq)]
enum Decision {
Charge { cents: i64 },
Reject { reason: &'static str },
}
// The core: no clock, no database, no network. Just a decision.
fn decide(balance_cents: i64, amount_cents: i64, blocked: bool) -> Decision {
if blocked {
Decision::Reject {
reason: "account blocked",
}
} else if amount_cents > balance_cents {
Decision::Reject {
reason: "insufficient funds",
}
} else {
Decision::Charge {
cents: amount_cents,
}
}
}
fn main() {
// The shell would load these from a database and act on the decision.
println!("{:?}", decide(10_000, 1_999, false));
println!("{:?}", decide(1_000, 1_999, false));
println!("{:?}", decide(10_000, 1_999, true));
}
It prints:
Charge { cents: 1999 }
Reject { reason: "insufficient funds" }
Reject { reason: "account blocked" }
Every one of those cases is a test with no setup. The shell, which loads the balance and performs the charge, gets a handful of integration tests instead of dozens.
Injecting the dependency: four languages
Dependency injection is a sentence, not a framework: give an object what it needs instead of letting it fetch it. Martin Fowler named it in 2004 precisely because “Inversion of Control” had become too vague, and Cockburn’s own 2005 sample wires it with a hand-written constructor: public Discounter(RateRepository r).
Go and Rust: by hand, at the top
Go usually wires in main. There is no container in the standard toolchain, and Go’s best-known wiring tool, Google’s wire, generated the code at compile time and now opens its README with “This project is no longer maintained.” Its pitch is still the argument for doing it by hand: code written for wire “is useful even for hand-written initialization”. Runtime containers do exist in Go (uber-go/dig and uber-go/fx are maintained and widely deployed); they’re a choice, not the default.
Rust has two mechanisms, and the choice is visible in the type:
pub trait Store {
fn save(&self, customer: &str, amount_cents: i64) -> Result<String, String>;
}
pub struct Postgres;
impl Store for Postgres {
fn save(&self, customer: &str, amount_cents: i64) -> Result<String, String> {
Ok(format!("postgres saved {customer} ({amount_cents} cents)"))
}
}
// Injection by generic parameter: resolved at compile time, no vtable.
pub struct Orders<S: Store> {
store: S,
}
impl<S: Store> Orders<S> {
pub fn place(&self, customer: &str, amount_cents: i64) -> Result<String, String> {
if amount_cents <= 0 {
return Err("an order must cost something".to_string());
}
self.store.save(customer, amount_cents)
}
}
// Injection by trait object: resolved at run time, one copy of the code.
pub struct DynOrders {
store: Box<dyn Store>,
}
impl DynOrders {
pub fn place(&self, customer: &str, amount_cents: i64) -> Result<String, String> {
self.store.save(customer, amount_cents)
}
}
fn main() {
println!(
"{}",
Orders { store: Postgres }
.place("ada", 1999)
.expect("saved")
);
println!(
"{}",
DynOrders {
store: Box::new(Postgres)
}
.place("ada", 1999)
.expect("saved")
);
}
It prints:
postgres saved ada (1999 cents)
postgres saved ada (1999 cents)
The generic version is monomorphised: the Book’s words are that “we pay no runtime cost for using generics”, because the compiler makes a copy per type. The trait object version pays for a vtable lookup and loses inlining (Part 8), and buys late binding: “trait objects allow for multiple concrete types to fill in for the trait object at runtime.”
Two costs to know: a Box<dyn Store> requires the trait to be dyn compatible, and needs + Send + Sync before it can be shared across threads, while the generic version duplicates the function body per type, which the API guidelines list as its drawback (“the function body is duplicated”). The trait itself is the seam either way.
The test double then needs no extra machinery, because test code is compiled out entirely: “The #[cfg(test)] annotation on the tests module tells Rust to compile and run the test code only when you run cargo test, not when you run cargo build.”
Java and C#: containers, and what they actually check
Spring’s reference documentation states a preference and its reason: “The Spring team generally advocates constructor injection, as it lets you implement application components as immutable objects and ensures that required dependencies are not null.” It keeps setter injection for the other case: “Setter injection should primarily only be used for optional dependencies”. And a constructor cycle fails loudly: Spring “detects this circular reference at runtime, and throws a BeanCurrentlyInCreationException”.
.NET has a container in the box, and its three lifetimes are the part people get wrong. From the ServiceLifetime enum: “Singleton […] a single instance of the service will be created”, “Scoped […] a new instance of the service will be created for each scope”, “Transient […] a new instance of the service will be created every time it is requested.”
Our lab registers one of each, then resolves all three twice inside two scopes:
Instance numbers printed by checks/part13_wiring/csharp/lifetimes.cs, run by checks/part13_wiring/run.py against Microsoft.Extensions.DependencyInjection 10.0.0 with ValidateScopes and ValidateOnBuild turned on.
It prints:
scope 1, first resolve: transient 1, scoped 2, singleton 3
scope 1, second resolve: transient 4, scoped 2, singleton 3
scope 2, first resolve: transient 5, scoped 6, singleton 3
scope 2, second resolve: transient 7, scoped 6, singleton 3
Read the numbers: the transient is different every single time, the scoped is the same within a scope and different across scopes, and the singleton never changes.
Registering and resolving looks like this:
#:package Microsoft.Extensions.DependencyInjection@10.0.0
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddSingleton<IClock, SystemClock>();
services.AddScoped<IStore, PostgresStore>();
services.AddScoped<Orders>();
using var provider = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true,
});
using (var scope = provider.CreateScope())
{
var orders = scope.ServiceProvider.GetRequiredService<Orders>();
Console.WriteLine(orders.Place("ada", 1999));
}
public interface IClock
{
DateTimeOffset Now { get; }
}
public sealed class SystemClock : IClock
{
public DateTimeOffset Now => DateTimeOffset.UnixEpoch;
}
public interface IStore
{
string Save(string customer, long amountCents, DateTimeOffset at);
}
public sealed class PostgresStore : IStore
{
public string Save(string customer, long amountCents, DateTimeOffset at) =>
$"saved {customer} ({amountCents} cents) at {at:yyyy-MM-dd}";
}
// Orders asks for what it needs. It never builds or looks up either one.
public sealed class Orders(IStore store, IClock clock)
{
public string Place(string customer, long amountCents) =>
amountCents <= 0 ? "an order must cost something" : store.Save(customer, amountCents, clock.Now);
}
It prints:
saved ada (1999 cents) at 1970-01-01
The clock is a dependency like any other, which is what makes “what happens on the last day of the month” a test rather than a wait. (.NET 8 and later ship TimeProvider for exactly this.)
Java without a container is the same idea, and it’s worth writing once to see that the container is a convenience, not the pattern:
interface Store {
String save(String customer, long amountCents);
}
record InMemoryStore(java.util.List<String> saved) implements Store {
@Override
public String save(String customer, long amountCents) {
saved.add(customer);
return "saved " + customer + " (" + amountCents + " cents)";
}
}
final class Orders {
private final Store store;
// Constructor injection: an Orders cannot exist without a Store.
Orders(Store store) {
this.store = store;
}
String place(String customer, long amountCents) {
if (amountCents <= 0) {
return "an order must cost something";
}
return store.save(customer, amountCents);
}
}
void main() {
var saved = new java.util.ArrayList<String>();
// main is the only place that knows both sides.
var orders = new Orders(new InMemoryStore(saved));
IO.println(orders.place("ada", 1999));
IO.println(orders.place("ada", 0));
IO.println("stored: " + saved);
}
It prints:
saved ada (1999 cents)
an order must cost something
stored: [ada]
Spring recommends constructor injection for the reason visible here: the object is either fully built or not built at all. It also makes a circular dependency fail at startup rather than resolve to a half-initialised object.
The captive dependency. A singleton that takes a scoped service holds it forever, which quietly turns it into a singleton. Microsoft’s docs name it and credit the name: “The term ‘captive dependency’, coined by Mark Seemann, refers to the misconfiguration of service lifetimes, where a longer-lived service holds a shorter-lived service captive.”
Our lab registers exactly that and asks the container to validate:
AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: HoldsAScopedService Lifetime: Singleton ImplementationType: HoldsAScopedService': Cannot consume scoped service 'Scoped' from singleton 'HoldsAScopedService'.)
That check isn’t on by default. It runs “When an app runs in the development environment and calls CreateApplicationBuilder to build the host”, and not when you call new ServiceCollection().BuildServiceProvider() yourself, which is what a console app or a test does. The two flags also do different jobs, and our lab turns both on: with ValidateScopes alone the captive dependency throws when the singleton is resolved, not at build time; with ValidateOnBuild alone it isn’t caught at all. ValidateOnBuild is the second, different check: that every registration can actually be constructed. With it on, a missing registration becomes a startup failure instead of a request failure:
AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: NeedsSomethingUnregistered Lifetime: Scoped ImplementationType: NeedsSomethingUnregistered': Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate 'NeedsSomethingUnregistered'.)
Two more rules from the same guidance, both earned the hard way:
- The container owns disposal: “Services resolved from the container should never be disposed by the developer.” And the leak to know about: “Disposable transient services are captured by the container for disposal. This can turn into a memory leak if resolved from the top-level container.”
- Don’t reach into the container: “Avoid using the service locator pattern. For example, don’t invoke GetService to obtain a service instance when you can use DI instead. Another service locator variation to avoid is injecting a factory that resolves dependencies at runtime.”
When a singleton genuinely needs a scoped service, the supported way is to create a scope: “inject IServiceScopeFactory, create a scope, then resolve dependencies from the scope”. The commonest real-world case is ASP.NET Core middleware, which is built once for the app’s lifetime: “Inject the service into the middleware’s Invoke or InvokeAsync method. Using constructor injection throws a runtime exception because it forces the scoped service to behave like a singleton.”
Constructor selection surprises people. .NET picks “The constructor with the most parameters where the types are DI-resolvable”, and two constructors that are each satisfiable but neither a superset of the other throw an ambiguity exception. Spring’s tie-break is the greatest number of satisfiable dependencies, falling back to the default constructor. Jakarta’s DI specification avoids the question: “@Inject can apply to at most one constructor per class.”
One more difference that catches people moving between the two: Spring’s default bean scope is singleton, while .NET has no default at all, since you choose the lifetime at registration.
Explain it like I’m ten
Imagine a chef who needs eggs.
- The chef walks to the shop whenever a recipe calls for eggs. Now the kitchen can’t work when the shop is shut, and you can’t test the recipe without a real shop.
- Someone hands the chef a basket of eggs before cooking starts. The chef doesn’t care where they came from. At dinner service it’s the real shop; in a taste test it’s a plastic egg.
- The kitchen writes down what it needs (“a dozen eggs, room temperature”) and the shop’s job is to match that note, not the other way round.
The third one is the dependency inversion: the kitchen owns the note.
The precise version
- Walking to the shop is a direct dependency on a concrete thing: the business rules importing the database driver.
- Handing over the basket is dependency injection: the thing is passed in, usually to a constructor.
- The note is the port, an interface defined by the code that uses it, and the shop is the adapter.
- Where the analogy breaks: someone still has to do the shopping. That someone is
main,Program.csor the container, and it’s the one place that knows every concrete type.
Trade-offs
- Ports cost indirection. Every interface is a hop for a reader. Cockburn’s own count was two to four ports for an application, not one per class.
- A container is optional. Mark Seemann, who coined “captive dependency”, puts it plainly: “DI is a set of principles and patterns; DI Containers are optional helper libraries.” Go and Rust do without one; Java and .NET usually use one because framework lifetimes (per request, per scope) are worth automating.
- Reflection-based wiring moves errors to run time. A missing registration is an exception on the first request unless you turn on
ValidateOnBuild. Compile-time wiring (hand-written, or generated) can’t fail that way. - Testing without ports is possible and sometimes better. Google’s Go guidance: “Do not define interfaces on the implementor side of an API ‘for mocking’; instead, design the API so that it can be tested using the public API of the real implementation.” A real in-memory implementation often beats a mock.
- Layers are cheap to draw and hard to enforce. Cockburn’s warning about the new layer filling with business logic is the default outcome unless something checks it: an import linter, a module boundary, or a separate package.
- The functional core is easy to test and awkward at the seams. Pure decisions need all their inputs passed in, which means the shell does more loading up front.
Common mistakes
- An interface per class, named after its implementation.
IOrderServicewith one implementation isn’t a port; it’s a rename. Ports are named after what the core needs:Store,Clock,RateRepository. - The port declared next to the adapter. If
postgresdeclares the interface andordersimports it, the arrow still points outwards and nothing is inverted. - Database types crossing the boundary. Returning an ORM entity or a
sql.Rowsfrom a port drags the outside in. Pass simple data. - A singleton holding a scoped service. The classic ASP.NET Core version is constructor-injecting a
DbContextinto middleware. UseIServiceScopeFactory. - Resolving from the container in business code. That’s the service locator, and it hides dependencies from the constructor and from tests.
- Registering everything as a singleton “for performance”. Now anything with per-request state is a bug waiting for concurrency (Part 11).
- Treating the diagram as the architecture. The circles and hexagons are drawings. The Dependency Rule is the architecture, and only a check on imports enforces it.
Interview questions
Try to answer each one before opening the model answer.
1. What is the Dependency Rule, and how do you implement it?
Show a strong answer
- The rule: “source code dependencies can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle.”
- The mechanism: dependency inversion. The inner code declares an interface (a port); the outer code implements it. Control flows outwards, the source dependency points inwards.
- In practice: the business package imports no database, HTTP or framework packages.
mainknows both sides and wires them. - How you check it: an import test or linter, because nothing in the language enforces it.
Likely follow-up: “What crosses the boundary?” Simple data structures. Martin: “We don’t want to cheat and pass Entities or Database rows.”
2. How do hexagonal, onion and clean architecture differ?
Show a strong answer
- Ports and adapters (Cockburn, 2005) is about inside versus outside, with a port per kind of conversation and adapters for each technology. The hexagon has no numeric meaning.
- Onion (Palermo, 2008) draws rings with the object model at the centre: “all coupling is toward the center”, “Inner layers define interfaces. Outer layers implement interfaces”, and unlike a layered design “any outer layer can directly call any inner layer”. Palermo also limits its scope: “This architecture is not appropriate for small websites.”
- Clean (Martin, 2012) draws the same direction rule with named rings such as use cases and entities.
- The disagreement: Martin groups all of them as “dividing the software into layers”; Cockburn wrote his “to get away from the one-dimensional layered picture”.
- What’s shared: dependencies point at the domain, and the mechanism is DIP.
Likely follow-up: “Which would you use?” The vocabulary that your team will keep. What matters is the arrows and something that checks them, not the shape of the drawing.
3. Explain dependency injection without mentioning a framework.
Show a strong answer
- Definition: an object is given its collaborators instead of constructing or locating them. Usually through the constructor.
- Why: it makes dependencies visible in the signature, lets callers substitute implementations, and keeps construction in one place.
- Without a container:
mainbuilds the graph, as Go and Rust do, and as Cockburn’s own 2005 sample does. - With a container: you register types and lifetimes and it builds the graph, which pays off when the framework owns lifetimes such as per-request scopes.
- Fowler’s point (2004): “Inversion of Control” was too broad a name, so this specific pattern got its own.
Likely follow-up: “Is a service locator the same thing?” No: it hides dependencies inside the class. Fowler is even-handed, giving Service Locator “a slight edge due to its more straightforward behavior” for application classes while preferring injection for reusable libraries; Microsoft’s guidelines and Seemann tell you to avoid it.
4. What are transient, scoped and singleton, and what goes wrong?
Show a strong answer
- Transient: a new instance every resolve. Scoped: one per scope, which in a web app is one per request. Singleton: one per provider.
- What goes wrong: a captive dependency, where a singleton takes a scoped service and holds it past its scope. The container’s message: “Cannot consume scoped service ‘Bar’ from singleton ‘Foo’.”
- The check:
ValidateScopescatches lifetime mistakes,ValidateOnBuildcatches unbuildable registrations; both are on in Development with the host builder, off if you build a provider by hand. - The escape hatch: inject
IServiceScopeFactoryand create a scope. - Disposal: the container disposes what it creates, and disposable transients resolved from the root container leak.
Likely follow-up: “Where does this bite in ASP.NET Core?” Middleware, built once per app: inject scoped services into InvokeAsync, not the constructor.
5. Where should an interface live, and when should you not write one?
Show a strong answer
- With the consumer. The Go guidance is explicit: “Go interfaces generally belong in the package that uses values of the interface type.” That’s what makes the arrow point inwards.
- Not one per class. “The most common mistake is creating an interface before a real need exists.”
- Not for mocking alone: “design the API so that it can be tested using the public API of the real implementation” or with a real in-memory implementation.
- Write one when there are several implementations, you need a seam at a process boundary, or you want to break a dependency cycle, and even then Google’s guide calls that last one “often a signal of improperly structured packages”.
- In C# and Java, the same rule applies, though the interface usually lives in the domain project rather than the adapter’s.
Likely follow-up: “How do you test code that talks to a database, then?” Through the port with a fake for logic, and against a real database for the adapter, with containers if you can.
6. What is “functional core, imperative shell”?
Show a strong answer
- The shape: decisions in pure functions that take data and return values or events; I/O in a thin shell around them.
- Why: the core is testable with no setup and no doubles, and the shell’s small surface gets a few integration tests.
- Relationship to ports: the same direction of dependency at a smaller scale. The core knows nothing about the outside.
- Cost: the shell must load everything the core needs up front, and some workflows need several rounds of load-decide-act.
- Where it shines: pricing, validation, state transitions, scheduling: anything with rules worth testing exhaustively.
Likely follow-up: “How do you handle a decision that needs data you didn’t load?” Return a request for it, and loop in the shell; or split the decision into steps.
7. Your team says the architecture is “clean”, but the domain imports the ORM. What do you do?
Show a strong answer
- Name the violation: the domain depends on a detail, so the rule is broken regardless of folder names.
- Invert one dependency at a time: define the port in the domain, move the ORM behind an adapter, and let
mainwire them. - Move the data too: map ORM entities to domain types at the boundary rather than passing rows inwards.
- Add a check: an import test, an architecture test (ArchUnit in Java, a layer test in .NET,
go listordepguardin Go) so the rule is enforced, not promised. - Cockburn’s warning: without such a mechanism, “the organization finds a few years later that the new layer is cluttered with business logic.”
Likely follow-up: “How would you sequence that in a live codebase?” Start with the seam you need for testing or for a planned swap, keep both paths working, and migrate call sites gradually.
8. Does dependency injection make code slower or harder to follow?
Show a strong answer
- Speed: an interface call costs a virtual or interface dispatch, which Part 8 measured as small compared with real work; a container adds startup cost, not per-call cost. Rust’s generic injection costs nothing at run time, trading that for code size and no swapping at run time.
- Readability: yes, indirection makes “go to definition” land on an interface. That’s the price of the seam, so only pay it where you use it.
- Debugging: a container can turn a compile error into a run-time one. Turn on
ValidateOnBuild, or wire by hand. - Balance: ports at the process edges (database, HTTP, clock, queue), concrete types inside.
Likely follow-up: “How do you inject a clock?” A Clock port with a real implementation and a fixed one for tests; in .NET, TimeProvider.
Sources
- Labs:
system-design/checks/part13_wiring/(two Go modules and their import graphs, the .NET lifetime and validation runs, and the Rust injection sample); the Go, Rust, Java and C# programs above are run by the series’ code verifiers - Alistair Cockburn, Hexagonal Architecture (Ports and Adapters), 2005; Jeffrey Palermo, The Onion Architecture, 2008; Robert C. Martin, The Clean Architecture, 2012
- Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern, 2004, and Mocks Aren’t Stubs; Mark Seemann, Pure DI, 2014, and Service Locator is an Anti-Pattern, 2010; Gary Bernhardt, Boundaries, 2012
- .NET: dependency injection overview, service lifetimes, DI guidelines, ServiceProviderOptions, ASP.NET Core dependency injection
- Java: Spring Framework: dependency injection, Jakarta Dependency Injection
- Go: Code Review Comments: interfaces, Google’s Go style guide, Effective Go (2009, not updated), google/wire (no longer maintained) and the Go blog’s introduction to it
- Rust: the Book on generics, trait objects and test organization; the API Guidelines on flexibility
What to remember
- The architecture is the direction of the arrows, not the shape of the diagram.
- A port is an interface declared by the code that needs it. If the adapter declares it, nothing is inverted.
- Ports and adapters is about inside versus outside; clean architecture draws that as layers. They disagree about the picture, not the mechanism.
- Dependency injection is passing collaborators in. A container is an optional convenience that matters most where the framework owns lifetimes.
- Know your container’s lifetimes, and turn on both validations: a singleton holding a scoped service is the bug the check is named after.
- Keep the decisions pure and the effects at the edge, and most of your tests need no setup at all.
Point the arrows at the code you’re least willing to rewrite.