·

Applying Design Patterns And Architectural Constraints

Applying Design Patterns And Architectural Constraints

AI will generate code that works long before it generates code that fits — and the gap between those two things is where most architectural debt is born.

How AI Violates Architectural Constraints by Default

AI coding agents are optimized for local correctness, not global coherence. When you ask an agent to "add a caching layer to the user service," it will likely reach for the nearest working solution: importing a cache client directly into the service class, creating an ad-hoc wrapper, or sprinkling cache calls inline. The result is code that compiles, passes basic tests, and completely bypasses whatever architectural boundaries you have spent months enforcing.

This is not a failure of intelligence — it is a predictable consequence of the agent not knowing your architectural rules. Without explicit constraints, the agent defaults to the patterns most prevalent in its training data, which skew toward quick tutorials, Stack Overflow answers, and simple examples that prioritize brevity over structure.

The common violations engineers encounter in practice:

  • Layer bypass: a controller directly instantiating a Prisma client instead of going through the repository layer
  • Dependency inversion failure: a service class importing a concrete infrastructure adapter rather than depending on an interface
  • Domain model pollution: business logic leaking into HTTP handlers or database models
  • Cross-boundary imports: a feature module importing directly from another feature module instead of going through a shared interface
  • Singleton abuse: shared mutable state managed as a module-level variable instead of being injected

Each of these violations is easy to generate accidentally and expensive to unwind later, especially when the pattern replicates across a dozen agent-generated files before anyone notices.

Learning tip: Run your architectural linter (eslint-plugin-import restricted zones, or a custom rule) immediately after every AI-generated batch. Architectural violations caught at generation time cost five minutes to fix. Caught at code review, they cost an argument. Caught in production, they cost a rewrite.

Specifying Architectural Constraints in Prompts

The most reliable way to prevent architectural violations is to state your architecture explicitly in every prompt that touches architecture-sensitive code. This does not need to be a lengthy essay — a structured constraint block of six to ten lines is enough. The key is to be specific about what is forbidden, not just what is desired.

A constraint block for a layered architecture might look like:

ARCHITECTURE CONSTRAINTS — apply strictly to all generated code:

1. Layering: Handler → Service → Repository → Database. No layer may import from a layer above it.
2. Dependency inversion: Services and repositories must depend on interfaces, not concrete implementations. Concrete classes are wired only in the composition root (src/container.ts).
3. No direct ORM usage outside repository files. Services must not import Prisma or any ORM client.
4. No business logic in handlers. Handlers only translate HTTP input to service calls and HTTP output.
5. Error translation: domain errors (NotFoundError, ValidationError) are only converted to HTTP status codes in the handler layer.
6. Feature module isolation: modules in src/modules/* must not import from each other directly. Shared types live in src/shared/.

Paste this block at the top of any prompt that generates service, handler, or repository code. It takes thirty seconds and prevents the most expensive category of AI-generated architectural debt.

Learning tip: Keep your architectural constraint block in a snippet file or in your CLAUDE.md. Copy-paste it at the start of any architecture-sensitive generation session. Treat it as a machine-readable architecture decision record.

Prompting for Specific Design Patterns

Design patterns require more than naming them. Saying "use the repository pattern" will produce wildly different outputs depending on how the agent interprets that pattern. The more reliable approach is to specify the structural contract of the pattern you want.

Repository pattern — specify the interface shape, the fact that it abstracts the data store, and that the service layer never sees ORM types:

Implement a user repository following the repository pattern.

Pattern requirements:
- Define an IUserRepository interface in a separate file. This is the contract the service layer depends on.
- The interface methods return domain types (User, not Prisma.User). Never expose ORM model types outside the repository file.
- The concrete class PrismaUserRepository implements IUserRepository and contains all Prisma-specific code.
- The repository is responsible for data mapping: converting Prisma models to domain types before returning.
- Errors from the ORM (e.g., Prisma's P2025 not-found error) must be caught inside the repository and re-thrown as domain errors (NotFoundError) before crossing the boundary.

Decorator pattern — specify the wrapping contract explicitly:

Add rate limiting to the UserService using the decorator pattern.

Pattern requirements:
- Create a RateLimitedUserService class that implements the same IUserService interface as UserService.
- Constructor takes IUserService and a RateLimiter as dependencies.
- Each method checks the rate limit before delegating to the wrapped service.
- If the rate limit is exceeded, throw a RateLimitExceededError (define this class).
- RateLimitedUserService must not contain any business logic — only rate limiting concern.
- UserService must not be modified.

Learning tip: When prompting for a design pattern, include one sentence explaining "what problem this pattern solves in this specific context." Agents produce more contextually appropriate implementations when they understand the motivation, not just the name.

Using CLAUDE.md to Encode Architectural Rules

CLAUDE.md is a project-level configuration file read by Claude Code at the start of every session. It is the most powerful tool available for encoding persistent architectural constraints, because it applies to every prompt in the session without you having to paste a constraint block every time.

An effective CLAUDE.md architecture section might include:

  • A brief description of the architectural style (hexagonal, layered, modular monolith)
  • The module/directory structure and what belongs in each layer
  • What is forbidden at each layer (explicit negative rules are more reliable than positive rules)
  • Naming conventions for interfaces, implementations, and DTOs
  • The composition root location (where concrete classes are wired)
  • Key domain error types and where they are defined

The more specific and negative your rules ("Services must NEVER import from src/infrastructure directly"), the more reliably the agent will respect them. Vague positive rules ("keep things clean") are effectively ignored.

Learning tip: Test your CLAUDE.md rules by intentionally asking the agent to do something your rules forbid and checking whether it refuses or self-corrects. If it does not, tighten the rule wording. Treat CLAUDE.md maintenance as part of your architecture practice.

Validating AI Output Against Architecture Principles

Prompt-level constraints reduce violations but do not eliminate them. You need automated validation that runs without human judgment. The toolchain for this:

TypeScript structural typing catches most interface violations at compile time. If your interfaces are correctly designed, importing a concrete class where an interface is expected will fail to compile — and the agent will usually catch this itself when given compiler feedback.

ESLint import rules (eslint-plugin-import with no-restricted-paths or import/no-cycle) catch layer bypass and circular dependency violations statically. Configure zones that match your architectural layers and run the linter in CI.

Dependency analysis tools (Madge, dependency-cruiser) can generate visual dependency graphs and enforce rules via configuration files. Running depcruise --validate .dependency-cruiser.json src as a CI step gives you architecture-as-code that fails the build on violations.

Manual review heuristic: when reviewing AI-generated code, always check three things first: what it imports, what it exports, and what exceptions it throws or swallows. These three things reveal most architectural violations before reading the implementation body.

Learning tip: Add your dependency-cruiser or ESLint architecture rules to the same CI step as type-checking. A failing architecture rule should block a merge just as surely as a failing type check. This removes the "we'll fix it later" escape hatch.

Hands-On: Implementing a Feature in Hexagonal Architecture

This exercise generates a notification feature — send an email when a user is created — that must conform to hexagonal (ports and adapters) architecture. The domain must not depend on the email provider.

Step 1 — Establish the architectural context

Before generating any code, document the architecture in your prompt header. Use this for every prompt in this exercise.

ARCHITECTURE: Hexagonal (Ports and Adapters)

Layer rules:
- Domain (src/domain/): pure business logic and entities. No imports from infrastructure or application layers.
- Application (src/application/): use cases and ports (interfaces). Imports only from domain. No framework or ORM imports.
- Infrastructure (src/infrastructure/): adapters implementing ports. Imports from application for port interfaces. Contains all framework-specific and I/O code.
- Composition root (src/container.ts): only place where infrastructure adapters are wired to application ports.

Forbidden:
- Domain must never import from application or infrastructure
- Application ports must never import from infrastructure adapters
- Use cases must depend on port interfaces, not concrete adapters

Step 2 — Define the domain event

[paste architecture constraint block above]

Generate a domain event for the user module in hexagonal architecture.

Requirements:
- Create a UserCreated domain event class in src/domain/events/UserCreated.ts
- Fields: userId (string), email (string), displayName (string), occurredAt (Date)
- The class is a plain value object — no methods except a constructor
- No imports outside of src/domain/

File: src/domain/events/UserCreated.ts

Step 3 — Define the notification port

[paste architecture constraint block above]
[paste UserCreated event file]

Generate the notification port (outbound port / driven port) for hexagonal architecture.

Requirements:
- Interface name: INotificationPort
- Location: src/application/ports/INotificationPort.ts
- Single method: sendUserWelcomeEmail(event: UserCreated): Promise<void>
- Imports UserCreated from domain
- No references to email providers, SMTP, or any infrastructure concerns

File: src/application/ports/INotificationPort.ts

Step 4 — Generate the use case

[paste architecture constraint block above]
[paste UserCreated event]
[paste INotificationPort interface]
[paste IUserRepository interface if available]

Generate the CreateUserUseCase in hexagonal architecture.

Requirements:
- Class: CreateUserUseCase in src/application/use-cases/CreateUserUseCase.ts
- Constructor takes IUserRepository and INotificationPort via dependency injection
- Method: execute(input: CreateUserInput): Promise<User>
- After creating the user, emit the UserCreated domain event by calling notificationPort.sendUserWelcomeEmail
- No HTTP, no ORM, no email SDK imports — only domain and port types
- Throw a ValidationError (define inline) if email is missing or invalid format

File: src/application/use-cases/CreateUserUseCase.ts

Step 5 — Generate the infrastructure adapter

[paste architecture constraint block above]
[paste INotificationPort interface]
[paste UserCreated event]

Generate the SendGrid adapter implementing INotificationPort.

Requirements:
- Class: SendGridNotificationAdapter in src/infrastructure/adapters/SendGridNotificationAdapter.ts
- Implements INotificationPort
- Constructor takes a SendGrid API key (string) and a fromAddress (string)
- Uses @sendgrid/mail to send the welcome email
- The subject line is "Welcome to [App Name], {displayName}!"
- All SendGrid-specific configuration and error handling is encapsulated here — do not let SendGrid error types cross the boundary. Catch SendGrid errors and rethrow as NotificationDeliveryError (define inline).

File: src/infrastructure/adapters/SendGridNotificationAdapter.ts

Step 6 — Wire in the composition root

[paste architecture constraint block above]
[paste CreateUserUseCase]
[paste PrismaUserRepository if available]
[paste SendGridNotificationAdapter]

Generate the composition root additions for the notification feature.

Requirements:
- In src/container.ts, instantiate SendGridNotificationAdapter using SENDGRID_API_KEY and SENDGRID_FROM_ADDRESS from process.env
- Instantiate CreateUserUseCase with the repository and adapter
- Export createUserUseCase as a named export
- Do not change any existing wiring — only add the new bindings

File: src/container.ts (additions only)

Step 7 — Verify architecture

Run depcruise --validate .dependency-cruiser.json src (or your equivalent linter). If any violations appear, paste the output back:

My dependency-cruiser reported these architecture violations after generating the notification feature:

[paste violation output]

For each violation, explain why it occurred and how to fix it without changing the public interfaces of the affected classes.

Key Takeaways

  • Architectural constraints are not enforced by naming patterns — they are enforced by explicit negative rules in prompts and automated linting in CI.
  • Paste a structured constraint block at the top of every architecture-sensitive prompt; do not assume the agent remembers or infers your architecture from prior context.
  • CLAUDE.md is your persistent architectural rulebook; invest in keeping it specific, negative, and tested.
  • Design patterns require structural specification, not just naming — describe the contract, the wrapping behavior, and what must not change.
  • Hexagonal architecture is particularly well-served by AI generation because ports are explicit contracts; generate ports first and adapters second, just as you would types-before-consumers.