Code Rule #02: Annotations Are Not Architecture
- Mateusz Roguski
- Apr 18
- 8 min read
Updated: May 3

Table of Contents
Introduction
Annotations (or attributes, depending on your language) have become a widespread tool for reducing boilerplate and wiring up frameworks. They’re everywhere — marking routes, configuring services, mapping database entities, triggering validation, injecting dependencies.
Annotations are not architecture. They’re metadata. Helpful, yes — but they don’t define structure, intent, or clarity. And when overused, they cause more harm than good.
Here are reasons why:
Tight Coupling & Lock-In
Annotations embed framework concerns directly into your code. This ties your application logic to specific technologies and makes it painful to switch frameworks or even upgrade versions. The separation between business logic and infrastructure dissolves.
Reliance on Runtime Reflection
Annotation-driven systems often depend on runtime reflection to interpret behaviour. This means errors appear at runtime instead of compile time, and debugging becomes harder. What could’ve been a compiler-verified design turns into guesswork.
Reduced Testability
Code that relies on annotations is harder to test in isolation. You often need to spin up a full container or runtime context just to activate the behaviour behind an annotation. This slows down tests, introduces global state, and makes mocking complex.
Hidden / Implicit Control Flow
Annotations make things happen without being explicitly called. Logic may execute simply because a class is annotated, making it difficult to trace execution paths. This hidden control flow turns the codebase into a black box.
Architectural Obfuscation
Annotations scatter configuration throughout the code, making it hard to understand how the system is wired together. The high-level design disappears into a sea of metadata. You can’t see the shape of the system by reading the code.
Configuration vs. Implementation Blur
Annotations mix setup with behaviour. It becomes unclear which part of a class is actual logic and which part is just configuration. The architectural drawing gets smudged.
This breaks a fundamental design principle: configuration should be separated from implementation. When these concerns are intertwined, it's harder to reason about, change, or reuse either one independently. Explicit configuration in separate files or bootstrap code makes relationships clearer and encourages better modularity.
Violation of Layered Architecture (esp. in DDD)
When annotations for persistence, serialisation, or validation appear in your domain model, they pull infrastructure concerns into the heart of your business logic. This breaks the independence of the domain layer and violates clean architecture principles.
Diminished Readability & Static Analysis
Annotations hide behaviour from both developers and tools. IDE navigation, static analysis, and "find usage" often fail to reveal what an annotation does or where it takes effect. The result? Code that's harder to understand and maintain.
Harder Debugging & Troubleshooting
You can’t set a breakpoint on an annotation. And when something breaks, it’s usually inside framework internals. You end up sifting through stack traces, hoping to guess which annotation caused the failure.
Steep Learning Curve / Poor Onboarding
New developers can't understand system behavior just by reading the code. They have to learn the framework's implicit rules and the hidden behavior annotations introduce. This slows onboarding and increases the chance of mistakes.
Runtime Performance Overhead
Annotations often require scanning and processing via reflection, especially at startup. In large systems, this creates overhead and longer boot times. While caching helps, it introduces new complexities. Every metadata-driven decision made at runtime is one more thing the compiler can’t help with.
Design Principles Often Violated by Annotations/Attributes
Separation of Concerns (SoC): Annotations blur the line between configuration and logic by mixing cross-cutting concerns (e.g. persistence, routing, validation) directly into business classes.
Single Responsibility Principle (SRP): A class with annotations for routing, validation, serialisation, etc., is doing multiple jobs — violating the principle of having one reason to change.
Dependency Inversion Principle (DIP): Annotations often create implicit dependencies on framework behaviour, violating the intent of depending on abstractions rather than concretion or infrastructure.
Explicitness over Implicitness: Annotations favor implicit magic — behaviour is declared but not invoked, breaking the principle that code should clearly show what it does.
Encapsulation: Annotations can expose internal logic to external frameworks or bind internals to configuration that lives outside the class’s explicit API.
Layered Architecture / Clean Architecture: When domain entities are annotated with ORM, routing, or serialisation details, they become tightly coupled to infrastructure, breaking clean separation between layers.
Open/Closed Principle (OCP): Changing behaviour often requires editing annotations inside the class itself, rather than extending behaviour externally, which can limit the class’s extensibility.
Tell, Don’t Ask: Annotation-driven frameworks tend to operate with hidden orchestration. The system asks metadata what to do rather than your code telling it what to do explicitly.
Modularity / Reusability: Classes annotated for a specific framework are harder to extract and reuse in other contexts, violating principles of modular and portable design.
Acyclic Dependencies: Annotations can cause circular references and hidden dependency chains, especially when class discovery is done via scanning or autowiring.
Language and Framework Landscape: Where Annotations (and Their Equivalents) Take Over
Annotations aren't the only way architecture can become implicit. Decorators, macros, DSLs, naming conventions — all of these can behave just like annotations, with the same consequences: hidden control flow, tight coupling, and unpredictable behaviour.
This section explores languages and frameworks that make implicit behaviour easy (and often default), versus those that encourage explicit design and structure.
Languages Prone to Annotation Overuse
These languages either support annotations natively or encourage metadata-driven wiring through decorators or macros. They often rely on runtime reflection, and many popular frameworks built on them default to annotation-heavy design.
Language | Annotation Support | Notes |
---|---|---|
Java | ✅ Native annotations | Spring, Jakarta EE, Hibernate all rely on annotations |
C# | ✅ Attributes | ASP.NET and Entity Framework embed control via attributes |
PHP (8+) | ✅ Attributes | Symfony, Doctrine, API Platform use them for routing, DI, ORM |
Python | ⚠️ Decorators | FastAPI, Django use decorators heavily; behaviour is implicit |
TypeScript | ⚠️ Decorators (experimental) | Angular, NestJS rely on them for structure |
Kotlin | ✅ Annotations | Spring Boot + DI frameworks use annotations; reflection heavy |
Dart | ✅ Metadata annotations | Flutter uses @immutable, @override, and other tags |
Ruby | ⚠️ No native annotation keyword | DSLs (Rails) replace annotations; implicit wiring through macros |
Languages That Favor Explicit Architecture
These languages either lack native annotation support or encourage clarity through code structure, composition, and separation of concerns. Frameworks in these ecosystems tend to wire behaviour through code rather than metadata.
Language | Annotation Support | Notes |
---|---|---|
Go | ❌ None | Explicit, struct-tag-based metadata only for serialization |
Rust | ⚠️ Compile-time attributes | Macros used for codegen, but logic is explicit and safe |
Haskell | ❌ None | Pure functional approach with explicit composition |
Elixir | ❌ None | Uses macros, but system flow remains visible via pipelines |
JavaScript | ❌ None natively | Frameworks simulate decorators; core language is explicit |
Swift | ⚠️ Limited | Attributes used for interoperability, not control flow |
Frameworks That Rely on Annotations or Annotation-Like Magic
These frameworks make metadata central to how behavior is wired. That metadata may be annotations, attributes, decorators, or DSL-style macros — but the result is the same: behaviour is declared, not invoked.
Framework | Language | Notes |
---|---|---|
Spring (Boot) | Java | Annotation-driven: routing, DI, lifecycle, AOP |
Hibernate | Java | Entity mapping, lifecycle hooks all through annotations |
ASP.NET Core | C# | Controllers, validation, routing via attributes |
Symfony | PHP | Annotations/attributes used for routing, DI, serialization |
Doctrine ORM | PHP | Entities, relationships mapped through attributes |
Angular | TypeScript | Components, modules, DI through decorators |
NestJS | TypeScript | Heavily decorator-based design model |
Flutter | Dart | Behaviour tagged with attributes; state/data linked via metadata |
Ruby on Rails | Ruby | DSL macros like has_many, before_save wire behaviour implicitly |
Django | Python | Uses decorators and meta-classes to inject routing, validation, ORM logic |
These frameworks make it easy to blur configuration, behaviour, and structure — often at the expense of clarity and separation of concerns.
Frameworks That Support Explicit Wiring
These frameworks support annotations or decorators but give you the option to build without them — through config files, explicit code, service definitions, or constructor injection. They enable metadata-free architecture when you choose to prioritise structure.
Framework | Language | Annotation-Free Options |
---|---|---|
Symfony | PHP | YAML/XML for routing, DI, serialization |
Laravel | PHP | Explicit service container, fluent routing, no annotation dependency |
ASP.NET Core | C# | Middleware pipelines and manual registration fully supported |
Micronaut | Java | Compile-time DI, minimal reflection use |
FastAPI | Python | Decorators used, but code remains readable and explicit |
Express.js | JavaScript | Middleware and routing explicitly wired through code |
Go Fiber / Echo | Go | All behaviour declared manually — no annotations |
Elixir Phoenix | Elixir | Uses controller functions and pipelines — no metadata wiring |
Vapor | Swift | Explicit route declarations, DI via code |
Hanami (Ruby) | Ruby | Emphasizes explicit actions, clean architecture, minimal DSL use |
In these frameworks, architecture remains visible — not hidden behind metadata or naming conventions.
Annotations aren’t the only threat to architectural clarity. Any system that encourages implicit behavior — whether via metadata, decorators, or DSLs — should be used with caution. The more magic your framework does for you, the more deliberate your structure must be.
What to Do Instead
Instead of relying on annotations to wire your system, structure it explicitly. Here are better practices to guide your design:
Use explicit wiring — prefer declarative configuration or composition over annotation-based auto-wiring.
Define wiring (like dependency injection, routing, etc.) in the application or infrastructure layers — not inside your business logic.
Separate configuration from implementation — keep setup in dedicated config files or bootstrap code.
Compose behaviour instead of decorating it — use functions, handlers, or pipelines instead of metadata.
Design behaviour to be statically visible — structure code so flow and relationships are obvious.
Keep your core pure — avoid annotating domain models or business rules; isolate framework dependencies to adapters.
Organize by architecture, not annotation — follow DDD or layered principles with clear folders and module boundaries.
Use frameworks as tools — bend them to your system’s needs, not the other way around.
Architecture isn’t how little you write — it’s how clearly your system communicates its intent.
Choose clarity. Choose structure.
Design Principles This Approach Promotes
Separation of Concerns — configuration lives outside the logic; layers remain independent.
Single Responsibility Principle — each class does one thing, and only one.
Explicitness Over Implicitness — behavior is defined in visible, navigable code.
Encapsulation — internal logic isn’t exposed to framework mechanisms.
Layered Architecture / Clean Architecture — domain remains isolated from infrastructure.
Open/Closed Principle — behavior can be extended without modifying the core.
Tell, Don’t Ask — your code drives the system, not metadata scanning.
Modularity & Reusability — logic is portable across contexts and frameworks.
Acyclic Dependencies — dependencies are defined explicitly, not discovered through scanning.
Testability — classes are easily testable in isolation without framework bootstrapping.
Impact on AI-Driven Development
In an era where AI tools help us understand, navigate, and even generate code, clarity and structure have never been more important.
Annotations hide meaning — AI models rely on explicit syntax and structure to understand intent. Metadata-driven behavior is often invisible to them.
Implied logic breaks inference — AI can’t follow control flow that isn’t expressed as code. Hidden behaviour causes hallucinations or missed connections.
Opaque systems confuse tooling — Code completion, refactoring, or explanation tools work best with visible logic and contracts, not metadata conventions.
Implicit contracts reduce reliability — If AI can’t see how your system works, it can’t safely modify or extend it.
By writing code that favours clear boundaries, explicit control flow, and modular structure, you’re not only helping humans — you’re helping machines help you.
Good architecture is machine-readable architecture.
And annotations, by nature, aren’t.
Summary: Why "Annotations Are Not Architecture"
Annotations are useful — but they are not architectural tools. They offer convenience at the cost of clarity, predictability, and maintainability. When overused, they hide behaviour, break encapsulation, and erode architectural boundaries.
Instead, prefer explicit design, visible wiring, and strict separation of concerns. This leads to systems that are not only easier to reason about and test, but also more readable by both humans and machines.
Annotations can help — but they must never define the system.
Because in well-architected software, behaviour is intentional, not inferred.