1Home2Projects3Experience4Education5About6Blog7Music
← All Posts
Software DesignCohesion & DecouplingClean ArchitectureDDDSOLIDJavaSpring Boot

My Go-To Architecture for Medium-Scale Projects

June 11, 2026 · 14 min read

My Go-To Architecture for Medium-Scale Projects cover

Every backend I build past a weekend prototype tends to converge on the same shape. Not because I'm dogmatic about it, but because this structure keeps paying me back: features stay isolated, the business rules stay testable without a running framework, and swapping a database or a web layer never turns into a rewrite. This is the architecture and folder layout I reach for by default on medium-scale projects — large enough that maintainability matters, small enough that I don't want ceremony for its own sake.

The examples below are deliberately generic. I'll use a simple BlogPost feature to illustrate, because it's a domain everyone understands. The point isn't any particular product — it's the pattern.

Two principles first: cohesion and decoupling

Before any of the architecture talk, I want to be honest about the order of priorities. I do apply Clean Architecture and Domain-Driven Design — but only to a degree, and never as the goal in itself. The two principles I genuinely optimize for on every project are cohesion and decoupling. Everything else is a means to those ends.

  • High cohesion — things that change together live together. Everything about one capability sits in one place, and any given class or module has a single, clear reason to exist.
  • Low coupling (decoupling) — things that change for different reasons can change independently. Each part depends on as little as possible, and on abstractions rather than concrete details, so a change over here doesn't ripple over there.

Read the rest of this article through that lens. The layered structure, the ports and adapters, the separate build modules, even the way I hide the framework — every one of them is just cohesion and decoupling applied at a different scale. Clean Architecture, DDD, and SOLID are the tools I use to get there; they aren't the point. When a "best practice" stops buying me cohesion or decoupling on a given project, I drop it without guilt.

Three layers, one direction

The whole thing is three layers, and the entire design hinges on one rule about which way dependencies are allowed to point:

core/          ← pure domain: entities, value objects, use cases, ports
application/   ← use-case orchestration + a security/authorization gate
external/      ← framework adapters: web, persistence, security, storage, email

dependency rule:   external  →  application  →  core
                   (core never imports from application or external)

Dependencies only point inward. external knows about application and core; application knows about core; and core knows about nothing else in the codebase. The domain sits at the centre, oblivious to how data arrives or where it's stored. That single constraint is what makes everything else fall into place.

Folder structure: slice by feature, not by type

Inside each layer I organize by feature (a vertical slice), not by technical type. I never want a controllers/ folder with forty unrelated controllers in it. I want everything about blogging to live together:

core/
  domain/
    blog/
      entity/        BlogPost.java, BlogCategory.java
      repository/    IBlogRepository.java        (port — an interface)
      usecase/       CreateBlogUseCase.java, PublishBlogUseCase.java, ...
  exception/
    error_code/      ErrorCode.java

application/
  service/
    blog/
      create_blog/   CreateBlogServiceInput / Output / Service / Proxy
      publish_blog/  ...
  domain/            app-level helpers (e.g. a permission checker)

external/
  web/               REST controllers, request/response DTOs, exception handler
  repository/
    blog/            BlogRepositoryAdapter, BlogDocument, BlogModelMapper
  security/          JWT, authentication, authorization wiring
  configuration/     storage, payments, seeding, etc.

This is cohesion made visible: because things that change together live together, onboarding someone to "the blog feature" means pointing at three folders that mirror each other across the layers, and deleting a feature is deleting three folders. Nothing is scattered, and no slice reaches into another's internals — high cohesion inside a slice, low coupling between slices.

Clean Architecture in practice: ports and adapters

The most valuable idea I borrow from clean architecture is dependency inversion: the domain declares what it needs as an interface (a port), and the outer layer provides the implementation (an adapter). The domain depends on the abstraction; the infrastructure depends on the domain.

So the core layer owns the repository interface:

// core/domain/blog/repository/IBlogRepository.java
public interface IBlogRepository {
    Optional<BlogPost> findByIdentity(Identity identity);
    BlogPost persist(BlogPost blogPost);
    void deleteByIdentity(Identity identity);
}

And the external layer implements it with whatever database happens to be in fashion this year:

// external/repository/blog/BlogRepositoryAdapter.java
@Repository
public class BlogRepositoryAdapter implements IBlogRepository {
    private final MongoTemplate mongoTemplate;
    // ...maps BlogPost (domain) <-> BlogDocument (persistence) via a mapper
}

The use case talks to IBlogRepository and has no idea MongoDB exists. If a client later demands Postgres, I write one new adapter and delete the old one — the domain and every use case stay untouched. The same trick applies to storage, email, and payments: each is a port in the domain and an adapter on the edge.

Decouple at the module boundary, not just the package

Decoupling doesn't have to stop at package edges. On a project of any real size I pull the genuinely shared and the genuinely independent parts out into their own build modules — separately versioned, separately testable, each with a one-way dependency into it and nothing pointing back out. Two of them I reach for almost every time:

  • A shared-kernel module — the contracts and value objects everything agrees on. It holds the small building blocks (an Email, an Identity, a Money, a Date), the execution contracts (IExecutable, the use-case and service marker annotations, the security-gate template), and the common exceptions. The application depends on it; it depends on nothing of yours. That single rule keeps the vocabulary of the system in one cohesive place and stops every service from re-inventing its own slightly-different Email.
  • A security module — authentication, authorization, JWT issuing and rotation, OTP, and its own value objects (Password, Username, UserRole). Security is a cross-cutting concern with a life of its own, so it lives on its own with auto-configuration, and any application drops it in as a dependency. The business code asks "is this caller allowed?" and never learns how the answer is computed.

This is the same payoff cohesion and decoupling always deliver, just at the largest scale in the codebase: each module has one clear responsibility, you can reason about it and test it in isolation, and a change to how tokens are signed touches the security module and nowhere else. It's also where the layering claim gets teeth — those marker annotations and the security-gate template live in the shared kernel, so the application's domain never imports the web framework or the security implementation directly.

Domain-Driven Design: entities that protect themselves

The core layer is where DDD lives. Entities aren't anaemic bags of getters and setters — they enforce their own invariants, so an invalid object simply cannot exist. A blog post that refuses to be created without a title is far safer than validation scattered across controllers:

// core/domain/blog/entity/BlogPost.java
public class BlogPost implements Identifiable {
    private Identity identity;
    private String title;
    private BlogCategory category;
    private IUser author;
    private Url coverImage;
    private boolean isDraft;

    public BlogPost setTitle(String title) {
        if (title == null || title.isBlank()) {
            throw new InvalidDomainAttributeException(
                "Blog post title cannot be null or empty", K_Cat_0005);
        }
        this.title = title;
        return this;
    }
    // every setter guards an invariant the same way
}

A few DDD habits I keep:

  • Value objects over primitives. Things like Identity, Email, Url, and Date are small immutable types that validate themselves — and they're cohesive to a fault: an Email ships in the same folder as its own InvalidEmailException, so everything about what makes an email valid is in one place. They live in the shared-kernel module, so every service speaks the same language, and a signature like findByEmail(Email email) simply can't be called with a malformed string.
  • Ubiquitous language in the code. Package and class names read like the business: PublishBlogUseCase, CreateBlogCategoryUseCase. The folder tree is the feature map.
  • Repositories as domain ports. They speak in domain objects (BlogPost), never persistence models.
  • Use cases as the unit of behaviour. Each one captures a single intention, instead of a fat service accumulating every operation loosely related to an entity.

Here's a complete use case. Notice it depends only on a port and returns a small typed result:

// core/domain/blog/usecase/CreateBlogUseCase.java
@UseCase
public final class CreateBlogUseCase
        implements IExecutable<CreateBlogUseCase.Input, CreateBlogUseCase.Output> {

    private final IBlogRepository blogRepository;

    CreateBlogUseCase(IBlogRepository blogRepository) {   // package-private
        this.blogRepository = blogRepository;
    }

    @Override
    public Output execute(Input input) {
        BlogPost post = input.toBlogPost();   // entity validates itself here
        return Output.from(blogRepository.persist(post));
    }

    public record Input(String title, Identity categoryId, IUser author)
            implements IExecutableInput { /* builds + validates a BlogPost */ }

    public record Output(Identity identity) implements IExecutableOutput {
        static Output from(BlogPost post) { return new Output(post.getIdentity()); }
    }
}

SOLID, made concrete

I don't recite SOLID as a checklist, but this structure happens to satisfy it naturally:

  • Single Responsibility — one use case does one thing; one adapter talks to one piece of infrastructure.
  • Open/Closed — new behaviour is a new use case or a new adapter. I add files; I rarely edit existing ones.
  • Liskov Substitution — any IBlogRepository implementation is interchangeable behind the port, including a fake one in tests.
  • Interface Segregation — ports are narrow and per-feature, so nothing depends on methods it doesn't use.
  • Dependency Inversion — the whole "dependencies point inward" rule is DIP applied at the architecture level.

The four-file service: where orchestration meets authorization

Use cases are pure business logic — they don't know who the caller is or whether they're allowed. That concern lives in the application layer, in a small repeatable pattern. Every write or complex read is exactly four files:

application/service/blog/create_blog/
  CreateBlogServiceInput     record implements IExecutableInput
  CreateBlogServiceOutput    record implements IExecutableOutput
  CreateBlogService          package-private; orchestrates use cases
  CreateBlogServiceProxy     the bean; an authorization gate around the service

The proxy extends a small template that enforces the same order for every call: authorize → execute → apply post-conditions. It's the only place permissions are checked, so the rule is impossible to forget:

@Service
public final class CreateBlogServiceProxy
        extends AbstractServiceSecurityGateTemplate<Input, Output> {

    @Override protected void authorize(Input input) {
        permissionChecker.requirePermission(AdminPermission.BLOGS_CREATE);
    }
    @Override protected IExecutable<Input, Output> getService() {
        return new CreateBlogService(createBlogUseCase, getUserAccountUseCase);
    }
    @Override protected Output applyPostConditions(Output output) { return output; }
}

The controller in external/web is then trivially thin: parse the request, call the service, wrap the response. All the interesting logic lives behind the gate.

The detail I care about most: the domain doesn't know Spring exists

Here's the part I'm most deliberate about. The core and application layers carry no framework dependencies — not even Spring. The domain imports no org.springframework.* anywhere. You can compile and unit-test the entire business of the application with nothing but plain objects and a fake repository. No application context to boot, no test slices, no mocking a web request.

"But something has to create the beans," you're thinking — and yes. Dependency injection is the one framework concern that genuinely has to touch these classes. My answer is to abstract even that away. Instead of stamping Spring's @Component or @Service onto domain classes, I use my own annotations from the shared-kernel module:

// core use cases are marked with a custom annotation, not Spring's:
@UseCase                       // meta-annotated as a component under the hood
public final class CreateBlogUseCase implements IExecutable<...> { }

// application services use a custom @Service that is NOT org.springframework's:
@Service
public final class CreateBlogServiceProxy extends AbstractServiceSecurityGateTemplate<...> { }

@UseCase and @Service here are my annotations. Today they're meta-annotated to register as framework components, so Spring still wires everything automatically. But the domain code only ever sees the abstraction. If I ever wanted to move off Spring — to a lighter DI container, or a different framework entirely — I'd change the meta-annotation in one place in the shared-kernel module, and not a single use case or service would need editing. The framework becomes a swappable detail at the edge, exactly like the database.

That's the whole philosophy in one line: the things most likely to change — frameworks, databases, transports — are pushed to the outside and hidden behind abstractions the core owns.

When I reach for this — and when I don't

This shape is my default for medium-scale services: a real domain with rules worth protecting, a lifespan measured in years, and a team that will rotate. The up-front cost is a little more indirection and a few more files per feature, and it pays that back every time requirements shift, infrastructure changes, or a new engineer needs to find their way around.

For a throwaway script or a tiny CRUD endpoint with no real business logic, it's overkill — a single Spring service is fine, and pretending otherwise is just cosplay. The skill isn't applying Clean Architecture or DDD everywhere; it's keeping the two principles underneath them — high cohesion, low coupling — in view, and reaching for whichever tactic actually buys you those on the project in front of you. On medium-scale projects, in my experience, that's most of them.