DEV Community

Cover image for AI + Spring Boot: How to Avoid Architectural Drift
Duncan Brown
Duncan Brown

Posted on

AI + Spring Boot: How to Avoid Architectural Drift

Over the past couple of years, I’ve watched a lot of teams add AI features to backend systems.

The pattern is predictable.

It starts small:

  • “Let’s just call OpenAI from this controller.”
  • “We’ll clean it up later.”

A few weeks later:

  • SDK types leak into the domain.
  • Prompt strings are scattered across services.
  • Tests start hitting real APIs.
  • Infrastructure concerns bleed into business logic.
  • The boundaries that once felt clear become blurry.

Nothing explodes immediately; it just becomes harder to reason about.

Teams move fast and often leave discipline for later (only for it to become technical debt).

While this isn't always a big deal, in my experience, it's bitten more teams in the rear end than not.


The Real Risk

AI integration isn’t inherently messy, but integrating it without structural discipline is.

Most examples online focus on:

  • How to call the API
  • How to parse JSON
  • How to stream responses

Very few focus on:

  • Where that logic belongs
  • How to preserve domain purity
  • How to keep tests deterministic
  • How to prevent provider lock-in
  • How to keep AI-assisted refactors from eroding structure

That’s where long-term problems start.


A Safer Pattern (DDD + Hexagonal)

If you’re using Spring Boot with DDD or hexagonal architecture (I argue DDD paired with an hexagonal architecture is rarely a bad idea), AI integration should respect the same boundaries as any other external dependency.

We don't need to follow strict DDD for less-complex systems, but, at a minimum:

  • The domain defines a port.
  • The application layer orchestrates use cases.
  • The infrastructure layer implements the adapter.
  • Profiles control whether you use:
    • A stub (deterministic)
    • A real provider (OpenAI, etc.)

For example:

// Domain port
public interface AiService {
    AiAnalysis analyzeDocument(DocumentContent content);
}
Enter fullscreen mode Exit fullscreen mode

The domain depends on this interface — not on any SDK.

Then, in infrastructure:

// Infrastructure adapter
public class OpenAiAdapter implements AiService {

    @Override
    public AiAnalysis analyzeDocument(DocumentContent content) {
        // Call provider SDK
        // Parse structured JSON
        // Map to domain AiAnalysis
    }
}
Enter fullscreen mode Exit fullscreen mode

The quick, immediate wins:

  • Controllers never call OpenAI directly.
  • The domain never imports SDK types.
  • Tests can swap in a stub implementation.

Perhaps most importantly, the integration stays contained.


Stub by Default

One pattern I’ve found especially valuable:

Make stub AI the default.

That means:

  • Unit tests are deterministic.
  • Local development does not require API keys.
  • You can simulate issue scenarios reliably.

Then enable the real provider via profile:

SPRING_PROFILES_ACTIVE=openai
Enter fullscreen mode Exit fullscreen mode

This separates development discipline from production capability.

It may sound obvious, especially in the age of TDD, but I still see developers coupling specific AI providers (and, indeed, providers of various services) to their codebase right out of the gates, tests included.


AI-Assisted Development Needs Guardrails

There’s another layer most people ignore at their own peril.

Even if your architecture starts clean, AI coding assistants can gradually degrade it.

Without guardrails, tools will:

  • Introduce framework dependencies into the domain
  • Inline prompt strings in controllers
  • Refactor across layers
  • Create “utility” dumping-ground classes

The solution isn’t to stop using AI tools, especially when--targeted correctly--they can amplify your skillsets and productivity.

(As a small aside, I've found treating a coding agent as a smart "junior developer" of sorts to be the starting point of an effective mindset.)

It’s to make your architectural constraints explicit.

For example:

  • Domain purity rules
  • Ports & adapters boundaries
  • No SDK usage outside infrastructure
  • Deterministic testing requirements
  • Profile-based wiring constraints

Paired with something like ArchUnit (a personal favourite of mine), those constraints become build-time enforcement.

That dramatically reduces architectural drift.


Why I Built a Template Around This

I ended up building a Spring Boot template that demonstrates:

  • Vertical slice structure
  • DDD + hexagonal boundaries
  • AI behind a domain port
  • Stub AI by default
  • Profile-gated OpenAI integration
  • In-memory + Postgres adapters
  • Flyway migrations
  • ArchUnit enforcement
  • An AGENTS.md governance file designed for AI-assisted development

It’s intentionally minimal and doesn’t try to solve every problem.

What it does do is establish a disciplined baseline you can extend safely.

If you’re integrating AI into backend systems and care about architectural longevity, it may be useful to you, even in the age of vibe coding.

Feel free to leave any questions you might have in the comments! And if you think the template I put together might be of use to you, please let me know.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.