Your team agreed on the boundaries eighteen months ago, drew them, and wrote them down. Everybody can recite them. Ask three people whether a particular import crosses one and you will get two answers and a pause.
That is not a discipline problem. A boundary described in a document is checked by whoever remembers it at the moment they are in a hurry, which is the same as not being checked, and the drift is invisible because nothing goes red.
What a rule that runs actually is
An architecture test is a few sentences about packages, executed. The value is not the sentences, which you already had. It is that they now have a truth value.
@ArchTest
static final ArchRule availability_knows_booking_only_through_its_events =
noClasses().that().resideInAPackage(AVAILABILITY)
.should().dependOnClassesThat(
resideInAPackage(BOOKING).and(resideOutsideOfPackage(PUBLISHED_EVENTS)))
.because("events are the published language between these two; a call in "
+ "either direction would make them one deployable forever");
Note the because. When that fails at two in the morning, the reason it exists is
in the failure message, not in an ADR somebody has to go and find.
The first run is the point
I wrote eleven rules for a service I had built the week before, to boundaries I had chosen and believed in. Four failed immediately, and every one was a design problem rather than a rule problem.
A shared type nobody meant to share. One slice’s outbound port returned another slice’s domain object. It reads as a saved class and it is a shared kernel: two supposedly independent slices now recompile together and neither owner knows. Fixed by giving the consumer its own small type and mapping in the adapter.
A package name that made the rule lie. A directory named desk inside the
booking slice matched a rule about the desk slice, so the rule was quietly
answering a different question than the one I asked. Renaming the package fixed
the rule.
A real cycle, through the one place nobody watches. A single error-handling class imported every slice’s exceptions, and the shared package it lived in was imported by all of them. Each slice translates its own failures now.
A collision only a container notices. Two adapters onto the same upstream picked the same bean name and the context failed to start. That one is not architecture, and I only found it because these rules pushed me to boot the thing.
Four for eleven, on code written by somebody who was paying attention. That ratio is the argument.
Write the rule expecting it to fail
The practical advice is a small change in stance. Do not write a rule to confirm what you believe. Write it to find out, and read the first red carefully, because it is usually right and you are usually the thing that has to change.
Two failure modes to avoid while doing that:
- Do not weaken the rule to make it pass. If a violation is genuinely acceptable, that is an exception with a name and a reason, not a looser predicate. A rule with a hole nobody can see is worse than no rule.
- Do not assert what you cannot state. I tried to say “availability may depend on booking’s events and nothing else” and first wrote a rule that meant something different and passed. A rule that passes for the wrong reason is the most expensive kind, and it is the same failure as an assertion that tests existence when the invariant is uniqueness: green, and about the wrong thing.
Where to start on Monday
Pick the boundary your team argues about most and write one rule for it. Not a
suite, one rule, with a because naming the consequence you are avoiding.
If it passes, you have learned something cheap and you have a rule that will keep being true. If it fails, you have learned something that was going to cost a lot more later, and you found it on a Monday instead of during an incident.
If your architecture is agreed in a document and contradicted in the code, that’s the work I do.