Enforcing Software Architecture Living Documentation Conventions
This series of posts is a journey into the world of living documentation for software architecture. Our goal is to be able to generate all…
This series of posts is a journey into the world of living documentation for software architecture. Our goal is to be able to generate all kinds of architecture diagrams directly from the code with 100% accuracy.
Not only that, we want the diagrams at a system level rather than a single codebase, and of course we want to feed our AI agents with more detailed knowledge of architecture so they can be far more powerful.
The last post showed that tools like ts-morph can allow us to define our architectural definitions declaratively and even standardize them at a platform level so the effort to setup living documentation in an individual codebase tends towards negligible.
This post explores how we can add rules to enforce our rules. Ok, let me clarify: this post explores ways to ensure that architectural / living documentation conventions are not violated so we can 100% trust the living documentation generated from the code. Let’s go.
This is one post in a series. You can find the other posts here: https://medium.com/nick-tune-tech-strategy-blog/software-architecture-as-living-documentation-series-index-post-9f5ff1d3dc07
Enforce decorators with ESLint
What if our living documentation convention was simple — every class must have a decorator @Role(role) . Where role would be a pre-defined list such as the following:
export type ArchitecturalRole =
| 'Repository'
| 'Aggregate'
| 'HttpEndpoint'
| 'EventHandler'
| 'Ignored';
If we could enforce this rule across a codebase we wouldn’t need to mess around with custom DSLs and brittle rules like classNameEndsWith('Repository')
Well, I tried it. And it works. And because it’s a lint rule, we even get feedback directly in our IDE without needing to run any commands or executing tests.

And the equivalent error in the console when running lint
9:8 error Class "PizzaRepository" must have a @Role decorator. Valid roles: Repository, Aggregate, APIEndpoint, EventHandler, Other
And to confirm the error goes away when the attribute is added…

And returns when we don’t specify a valid role…

ESLint rule
The amount of effort to set up such a rule is minimal. An ESLint whizz kid might even have a simpler approach or utility that abstracts away a bit of this.
export const requireRoleDecorator = createRule<[], MessageIds>({
name: 'require-role-decorator',
meta: {
type: 'problem',
docs: {
description: 'Require all classes to have a @Role decorator for living documentation',
},
messages: {
missingRoleDecorator:
'Class "{{className}}" must have a @Role decorator. Valid roles: Repository, Aggregate, APIEndpoint, EventHandler, Other',
invalidRoleValue:
'Invalid @Role value "{{value}}". Must be one of: Repository, Aggregate, APIEndpoint, EventHandler, Other',
},
schema: [],
},
defaultOptions: [],
create(context) {
const filename = context.filename;
if (
filename.endsWith('.spec.ts') ||
filename.endsWith('.test.ts') ||
filename.includes('/__tests__/') ||
filename.includes('/test/')
) {
return {};
}
return {
ClassDeclaration(node: TSESTree.ClassDeclaration) {
if (!node.id) {
return;
}
const className = node.id.name;
const decorators = node.decorators || [];
const roleDecorator = decorators.find((decorator) => {
if (decorator.expression.type === 'CallExpression') {
const callee = decorator.expression.callee;
return (
callee.type === 'Identifier' && callee.name === 'Role'
);
}
return false;
});
if (!roleDecorator) {
context.report({
node,
messageId: 'missingRoleDecorator',
data: {
className,
},
});
return;
}
if (roleDecorator.expression.type === 'CallExpression') {
const args = roleDecorator.expression.arguments;
const firstArg = args[0];
if (firstArg && firstArg.type === 'Literal') {
const roleValue = firstArg.value;
if (
typeof roleValue === 'string' &&
!VALID_ROLES.includes(roleValue as (typeof VALID_ROLES)[number])
) {
context.report({
node: firstArg,
messageId: 'invalidRoleValue',
data: {
value: roleValue,
},
});
}
}
}
},
};
},
});
When would this approach work?
I think the decorator-based approach is appealing because it’s easy to enforce in our codebase if we are using classes. Whether we are working in a fresh greenfield or a legacy codebase, adding an attribute to each class is easy and safe.
As discussed in the last post, from a platform paved-road perspective, this could be a sensible default that gives living documentation for practically zero cost.
It might take a bit of effort to retrofit in a legacy codebase and it might be super boring and repetitive but it’s a safe change that we will only have to be done once and the results will be worth it. AI can certainly help.
When would this approach not work?
Class-level decorators obviously aren’t going to work if you’re not using classes. In the TypeScript eco-system, there are many who prefer functional / function-based approaches so it’s a valid concern.
But that doesn’t mean you need to give up on decorator-style approaches (I know how many of you dislike decorators and I’m not saying they’re perfect, but we have to make compromises sometimes). You could apply a function-level rule.
With ESLint it’s possible to enforce that certain information is provided as a JSDoc parameter at the function-level.


What other rules might we need?
From a living documentation perspective, we almost-certainly want to enforce that certain model elements provide certain types of information. Like we want all of our event handlers to tell us which event they consume.
One approach here would be to avoid more ESLint configuration and just create specialized decorators which take the required information as a parameter and update the ESLint rule to ensure that any one of the role decorators is present
export function EventHandler(consumedEvent: string): ClassDecorator {
return (target: object) => {
...
};
}
But this approach starts to become brittle again. Rather than manually specifying the consumedEvent it would be better to take this from the source of truth — the value passed into MessageBus.subscribe(eventType) .
Writing a rule like that is possible but I think a simpler approach can work: an ESLint rule that says “Any class decorated with @Role('EventHandler') or @EventHandler must define a consumedEvent property”. It’s unlikely we’ll define this property and then pass a different value into MessageBus.subscribe
If you’re not willing to compromise with decorators, or unable, more nuanced rules may be needed based on your conventions. For example, using the DSL example from the last post:
Repository: {
detection: typeSuffix('Repository')
}
We would need to think about how to ensure that all repositories have the “Repository” suffix to ensure they are picked up. I’ll leave you to think about that one.
Give it a go…
I encourage you to start exploring tools for validating that your conventions are applied. If you’re looking to set up architectural living documentation, understanding your conventions and having confidence you can enforce them means you have solved a key piece of the puzzle and are building on solid foundations.
As I mentioned in the previous post, my personal feeling is that more standardization is better because it means less effort is required to achieve living documentation. But even if you prefer less standardization, enforcing your conventions might not be a huge effort.
Instea of ESLint you might want to check out ts-arch or other tools. I do think ESLint is very compelling with the out-of-the-box IDE support, but let me know if you have a better approach.