Method · Code discipline
Discipline is a claim until it is checked.
This page shows three pieces of our own production code: a layer rule kept cleanly, the same rule broken on a live endpoint, and the pre-commit gate that should have caught the break.
The rule.
CompanyGraph has a strict layered backend. Facade is a switchboard. BusinessServices own decisions. Repositories own data access. The internal docs state this in three places that have to agree:
- CLAUDE.md. “Facade is a switchboard, not a brain — it does no business decisions and no data access.”
- LAYER_BOUNDARIES.md §6. “Never return tuples across layer boundaries. If you need multiple things, put them on a named DTO or Model.”
- QUALITY_LENS Axis 2 / CLAUDE.md coding rules, rule 4. Return named domain types across layers. No tuples, no anonymous shapes crossing Facade ↔ BusinessService ↔ Repository.
Three documents, one rule. The rest of the page is what happens when that rule is followed, when it is not, and what catches the difference.
Kept: a service that does its job.
The conversion-funnel endpoint follows the rule cleanly. The BusinessService computes, returns a named Model. The Facade calls it and maps to a DTO. No layer reaches further than it should.
// BusinessServices/DashboardBusiness.cs:284
public async Task<ConversionFunnelModel> GetConversionFunnel(
int adminId, int? days = null, bool excludeNoReferrer = false)
{
// ... query, group, project to named Model ...
return new ConversionFunnelModel { Pages = pages, Totals = totals };
}// Facade/AdminFacade.cs — calls service, maps Model -> DTO, returns
var model = await _dashboardBusiness.GetConversionFunnel(adminId, days);
return model.ToDto();Three things hold here. The return type is a named domain Model, not an ad-hoc shape. The Facade does one thing: call the service, map the result, return. And the contract — what this endpoint returns — lives in one file, not two.
If a future edit changes which fields the funnel exposes, the change is local. The Model evolves. The DTO mapper evolves with it. Facade stays out of the way.
Drift: the live tuple leak.
The service-status endpoint breaks the same rule. The BusinessService method that reports database connection counts returns a tuple. The Facade destructures it and writes the values onto the DTO directly:
// BusinessServices/Api12DataBusiness.cs:47
public async Task<(int active, int max)> GetConnectionCountsAsync()
{
// ... open conn, query pg_stat_activity ...
return (reader.GetInt32(0), reader.GetInt32(1));
}// Facade/AdminFacade.cs:391
public async Task<ServiceStatusDto?> GetServiceStatus(int adminId)
{
var model = await _api12DataBusiness.GetServiceStatusAsync(adminId);
if (model == null) return null;
var dto = model.ToDto();
var (active, max) = await _api12DataBusiness.GetConnectionCountsAsync();
dto.ActiveConnections = active;
dto.MaxConnections = max;
return dto;
}The cost is concrete, not aesthetic.
- Position-based fields invite a silent swap.
(int active, int max)is a positional contract. If a future edit reverses the projection order —maxfirst,activesecond — the code still compiles, the tests still pass, and the dashboard reports a wrong number with full confidence. A named Model would have made the swap impossible. - DTO assembly is split across two files. Part of the response shape is filled by
model.ToDto(); the rest is patched on by the Facade after a second service call. The question “what does this endpoint return?” no longer has one answer. It has one-and-a-half. - Two files now move together. Adding a third connection metric — idle, waiting, replication lag — means changing the tuple shape in the service, the destructure in the Facade, and the DTO field list. Three coordinated edits to add one number.
The rule is explicit: “Never return tuples across layer boundaries. If you need multiple things, put them on a named DTO or Model.” (LAYER_BOUNDARIES.md line 109.) This endpoint breaks the rule. It has been on production since the connection-count display was added in April 2026.
Gate: what catches drift, and what doesn't.
The Facade has a pre-commit gate. It runs on every commit and refuses to let two specific kinds of layer violation merge:
# scripts/check-facade-boundaries.sh — wired into .githooks/pre-commit
INCLUDE_PATTERN='\.IncludeDepth\(([2-9]|[1-9][0-9]+)\)'
MUTATION_PATTERN='_context\.(Add|Remove|SaveChangesAsync)\('
grep -rnE "$INCLUDE_PATTERN" "Facade/" --include='*.cs'
grep -rnE "$MUTATION_PATTERN" "Facade/" --include='*.cs'- What it catches today.
IncludeDepth(n ≥ 2)inside Facade — navigation graphs being loaded at the wrong layer._contextmutations inside Facade — writes happening outside BusinessServices. Both fail the commit, name the file, and point at the doc. - What it does not catch yet.
Task<(...)>return types from a BusinessService method — the exact shape of the tuple-leak above. The gate enforces two boundary rules and is silent on the third. That silence is why the Drift section is still live, on production, and not yet fixed.
The gate is honest about its own coverage. It refuses what it knows how to refuse and is openly silent about the rest. The next iteration adds a tuple-return check over BusinessServices/. Until that lands, the rule about tuples is a claim the code does not yet fully back.
Why a live leak, not a clean example.
The order on this page is deliberate. The drift exists. The gate doesn't catch it yet. We could have fixed the tuple, extended the gate, and only then written this page. We didn't. The published version of this code has the leak in it. You can verify the file and the line.
A synthesized clean example proves less than a live leak does, because the rule is the spec the example is written to. What a working system shows is the gap between the rule, the code, and the gate — and whether the team can see the gap without being told.
The follow-up is bounded. Fold the connection counts into ServiceStatusModel, or introduce ConnectionCountsModel as its own named return type. Remove the destructure in the Facade. Extend the pre-commit gate to flag Task<(...)> returns. Each step is small, reversible, and observable.
The point is not perfect code. The point is code that makes drift visible.
This is the kind of check we run on inherited systems too.
The same eye that finds a tuple leak in our own Facade is the eye we bring to a backend you have inherited — not as a code-quality lecture, but as evidence about which rules in your system are live, which are aspirational, and which are quietly broken.