🏗️ The Blueprint Before the Build: Thinking in Layers

Every tangled codebase started as a clean idea. This article introduces the SLICED framework for layered architecture in Spring Boot, with automated ArchUnit rules that enforce boundaries for you.

Layered architecture in Spring Boot — the SLICED framework with ArchUnit rules

📚 Series Navigation:
👉 You are here: Part 1 - The Blueprint Before the Build
Next: Part 2 - Spring Boot Alchemy →


📋 Introduction

Picture this: it's 3 AM. Your phone is buzzing like an angry wasp. The on-call alert reads: "Weather microservice returning incorrect forecast data." You grab your laptop, open the codebase, and stare at a 2,000-line WeatherManager class that handles API calls, database queries, JSON transformation, validation, caching, and — for some inexplicable reason — email notifications.

Where do you even begin?

We've all been there. The monolithic class. The circular dependency hell. The service that imports half the known universe. These aren't just code smells — they're architectural failures that compound over time, turning every bug fix into an archaeology expedition and every new feature into a game of Jenga played with live ammunition.

But here's the thing: architecture isn't about following patterns for the sake of patterns. It's about making the next developer's life easier — and that next developer is usually future you, running on coffee and regret at 3 AM.

In this first article of our 13-part series, we're going to explore how the Weather Microservice uses layered architecture to stay clean, testable, and maintainable. More importantly, we're going to see how it enforces those layers with automated tests so they don't erode over time. Because rules without enforcement are just suggestions.

Let's dive in. ☕


🧱 The SLICED Framework: Six Pillars of Layered Architecture

Before we look at any code, let's establish a mental model. I call it SLICED — six principles that guide how we think about layers:

Letter Principle What It Means
S Separation Each layer has one job, and it does it well
L Layered Dependencies Dependencies flow downward — never up, never sideways
I Interface Contracts Layers communicate through DTOs and defined contracts
C Constructor Injection Dependencies are explicit, final, and injected via constructors
E Enforced Boundaries Architectural rules are verified by automated tests
D Directional Flow Requests flow Controller → Service → Repository in one direction

These aren't just nice-to-have principles. They're the load-bearing walls of your application. Remove one, and the whole thing eventually collapses.

Let's see how the Weather Microservice implements each of these.


🔬 Separation: One Layer, One Job

The Weather Microservice follows a classic four-layer architecture:


+-----------------------------+
|     Controller Layer        |  ← HTTP concerns: routing, validation, response codes
+-----------------------------+
|     Service Layer           |  ← Business logic: orchestration, transactions, caching
+-----------------------------+
|     Mapper Layer            |  ← Data transformation: entity ↔ DTO conversion
+-----------------------------+
|     Repository Layer        |  ← Data access: database queries, persistence
+-----------------------------+

Each layer has a clear, singular responsibility. Let's look at how the controller layer stays thin:

The Thin Controller

// WeatherController.java
@RestController
@RequestMapping("/api/weather")
@RequiredArgsConstructor
@Validated
public class WeatherController {

  private final WeatherService weatherService;

  @GetMapping("/current")
  public ResponseEntity<WeatherDto> getCurrentWeather(
      @RequestParam @NotBlank(message = "Location cannot be blank") String location,
      @RequestParam(defaultValue = "true") boolean save) {
    WeatherDto weather = weatherService.getCurrentWeather(location, save);
    return ResponseEntity.ok(weather);
  }
}

Notice what this controller does not do:

  • ❌ No database queries
  • ❌ No business logic
  • ❌ No data transformation
  • ❌ No direct API client calls
  • ❌ No caching logic

It does exactly three things:

  1. ✅ Accept the HTTP request
  2. ✅ Validate the input (@NotBlank, @Positive)
  3. ✅ Delegate to the service and return the response

This is the essence of a thin controller. It's a traffic cop, not a detective. It directs traffic but doesn't investigate crimes.

💡 Pro Tip: If your controller method is longer than 10 lines, something from another layer is probably leaking in.

The Service Layer: Where the Magic Happens

The service layer is where business logic lives. Here's where we orchestrate operations, manage transactions, and apply caching:

// WeatherService.java
@Service
@RequiredArgsConstructor
@Validated
public class WeatherService {

  private final WeatherApiClient weatherApiClient;
  private final WeatherRecordRepository weatherRecordRepository;
  private final LocationService locationService;
  private final WeatherMapper weatherMapper;

  @Transactional(propagation = Propagation.REQUIRED)
  @Cacheable(value = "currentWeather", key = "'weather:byName:' + #locationName",
      unless = "#result == null")
  public WeatherDto getCurrentWeather(@NotBlank String locationName, boolean saveToDatabase) {
    WeatherApiResponse apiResponse = weatherApiClient.getCurrentWeather(locationName);

    if (saveToDatabase) {
      saveWeatherRecord(apiResponse);
    }

    WeatherDto result = weatherMapper.toDtoFromApi(apiResponse);
    log.debug("Successfully fetched current weather for location: {} (temp: {}°C)",
        locationName, result.temperature());
    return result;
  }
}

The service layer orchestrates the flow: call the external API client, optionally save to the database, transform the response. It knows what to do but delegates the how to specialized components.

Key observations:

  • @Transactional — Transaction management belongs here, not in controllers or repositories
  • @Cacheable — Cache decisions are business decisions (how stale can weather data be?)
  • Dependencies are all from lower layersWeatherApiClient (client layer), WeatherRecordRepository (repository layer), WeatherMapper (mapper layer)

The Mapper Layer: The Translation Desk

One of the most underappreciated layers is the mapper. It converts between entity objects (tied to database schema) and DTOs (tied to API contracts):

// WeatherMapper.java
@Component
public class WeatherMapper {

  private static final DateTimeFormatter DATETIME_FORMATTER =
      DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");

  public WeatherDto toDto(WeatherRecord weatherRecord) {
    if (weatherRecord == null) return null;

    if (weatherRecord.getLocation() == null) {
      throw new IllegalArgumentException(
          "WeatherRecord location is null. Ensure @EntityGraph or JOIN FETCH is used.");
    }

    return new WeatherDto(
        weatherRecord.getId(),
        weatherRecord.getLocation().getId(),
        weatherRecord.getLocation().getName(),
        weatherRecord.getTemperature(),
        weatherRecord.getFeelsLike(),
        weatherRecord.getHumidity(),
        // ... remaining fields
        weatherRecord.getTimestamp());
  }

  public WeatherRecord fromWeatherApi(WeatherApiResponse apiResponse, Location location) {
    WeatherApiResponse.CurrentWeather current = apiResponse.getCurrent();
    LocalDateTime timestamp = parseTimestamp(apiResponse.getLocation().getLocaltime());

    return WeatherRecord.builder()
        .location(location)
        .temperature(current.getTempC())
        .feelsLike(current.getFeelslikeC())
        // ... remaining fields
        .timestamp(timestamp != null ? timestamp : LocalDateTime.now())
        .build();
  }
}

Why does this matter? Because database schemas change independently of API contracts. When you add a column to your weather_records table, the mapper absorbs the change. Your API consumers never know. When you rename a JSON field in your API response, the mapper adapts. Your database schema stays clean.

The mapper also contains defensive checks — like verifying the location isn't null (which would indicate a lazy loading issue). This is layer-appropriate validation: the mapper knows about entity relationships and DTO contracts.

DTOs: The Immutable Messengers

The Weather Microservice uses Java records as DTOs — immutable, concise, and purpose-built:

// WeatherDto.java
@Schema(description = "Current weather information")
public record WeatherDto(
    @Schema(description = "Weather record ID", example = "1") @Nullable Long id,
    @Schema(description = "Location ID", example = "1") @Nullable Long locationId,
    @Schema(description = "Location name", example = "London") String locationName,
    @Schema(description = "Temperature in Celsius", example = "15.5") Double temperature,
    @Schema(description = "Feels like temperature", example = "13.2") Double feelsLike,
    @Schema(description = "Humidity percentage", example = "65") Integer humidity,
    @Schema(description = "Wind speed in km/h", example = "12.5") Double windSpeed,
    @Schema(description = "Wind direction", example = "NW") String windDirection,
    @Schema(description = "Weather condition", example = "Partly cloudy") String condition,
    @Schema(description = "Detailed description") String description,
    @Schema(description = "Pressure in mb", example = "1013.2") Double pressureMb,
    @Schema(description = "Precipitation in mm", example = "0.5") Double precipitationMm,
    @Schema(description = "Cloud coverage %", example = "40") Integer cloudCoverage,
    @Schema(description = "UV index", example = "3.5") Double uvIndex,
    @Schema(description = "Timestamp") LocalDateTime timestamp) {}

Records give you several things for free:

  • Immutability — Once created, a DTO can't be accidentally modified
  • equals(), hashCode(), toString() — Auto-generated from all fields
  • Compact syntax — No boilerplate getters, setters, or constructors
  • Semantic clarity — It's obvious this is a data carrier, not a service

🔥 Critical Insight: Notice that id and locationId are @Nullable. When weather is fetched directly from the external API without saving, these fields are null. The DTO's contract is explicit about this.


⬇️ Layered Dependencies: The One-Way Street

In a layered architecture, dependencies must flow in one direction — downward. Here's the dependency graph for the Weather Microservice:

Controller  →  Service  →  Repository
     ↓            ↓            ↓
    DTOs       Mapper        Models
                  ↓
            DTOs + Models

The rules are simple:

  • Controllers can access Services, DTOs, and Exceptions
  • Services can access Repositories, Mappers, Clients, DTOs, Models, Exceptions, and Config
  • Repositories can only access Models
  • Mappers can only access DTOs and Models

What can never happen:

  • ❌ A Repository importing a Controller
  • ❌ A Model importing a Service
  • ❌ A DTO importing a Repository
  • ❌ A Controller directly accessing a Repository (bypassing the Service layer)

These rules exist because upward dependencies create coupling that resists change. If your repository knows about your controller, changing your HTTP contract requires modifying database code. That's madness.


💉 Constructor Injection: Dependencies Made Explicit

Every service in the Weather Microservice uses constructor injection via Lombok's @RequiredArgsConstructor:

@Service
@RequiredArgsConstructor
public class WeatherService {

  private final WeatherApiClient weatherApiClient;          // Client layer
  private final WeatherRecordRepository weatherRecordRepository; // Repository layer
  private final LocationService locationService;              // Service layer (peer)
  private final WeatherMapper weatherMapper;                  // Mapper layer

  // No @Autowired anywhere!
}

Why constructor injection over field injection?

Aspect Constructor Injection Field Injection (@Autowired)
Immutability ✅ Fields are final ❌ Fields are mutable
Testability ✅ Pass mocks via constructor ❌ Need reflection or @InjectMocks magic
Required deps ✅ Compiler enforces all deps ❌ Can be null at runtime
Readability ✅ Dependencies visible in one place ❌ Scattered across fields
Circular deps ✅ Fails fast at startup ❌ Silently creates runtime issues

The @RequiredArgsConstructor annotation from Lombok generates a constructor for all final fields. It's the best of both worlds: explicit dependency declaration without the boilerplate.

💡 Pro Tip: If you find yourself with more than 5-6 constructor parameters, your class is probably doing too much. Consider splitting it into smaller, focused services.


🛡️ Enforced Boundaries: ArchUnit to the Rescue

Here's where theory meets practice. Architectural rules are great in documentation, but developers are humans. Under deadline pressure, shortcuts happen. That beautiful layered architecture starts to look like spaghetti three months in.

The Weather Microservice solves this with ArchUnit — a library that tests architectural rules at compile time. If someone violates the architecture, the build fails.

The Full Layer Dependency Test

// LayerArchitectureTest.java
@Test
void layersShouldRespectDependencies() {
    ArchRule rule =
        layeredArchitecture()
            .consideringOnlyDependenciesInLayers()
            .layer("Controllers").definedBy("..controller..")
            .layer("Services").definedBy("..service..")
            .layer("Repositories").definedBy("..repository..")
            .layer("Models").definedBy("..model..")
            .layer("DTOs").definedBy("..dto..")
            .layer("Mappers").definedBy("..mapper..")
            .layer("Clients").definedBy("..client..")
            .layer("Config").definedBy("..config..")
            .layer("Exceptions").definedBy("..exception..")
            .whereLayer("Controllers").mayNotBeAccessedByAnyLayer()
            .whereLayer("Controllers")
                .mayOnlyAccessLayers("Services", "DTOs", "Exceptions", "Config")
            .whereLayer("Services")
                .mayOnlyAccessLayers("Repositories", "Mappers", "Clients",
                    "DTOs", "Models", "Exceptions", "Config")
            .whereLayer("Repositories").mayOnlyAccessLayers("Models")
            .whereLayer("Mappers").mayOnlyAccessLayers("DTOs", "Models")
            .withOptionalLayers(true);

    rule.check(importedClasses);
}

Here's what's happening:

  1. Define layers — Each package maps to a named layer
  2. Set access rules — Explicit allow-lists for what each layer can see
  3. Top layer is unreachable — Controllers can't be accessed by any other layer
  4. Check automatically — This runs in CI, every single build

If someone writes import com.weatherspring.controller.WeatherController; inside a repository class, this test catches it immediately. No code review needed. No architecture meetings. The build fails, and the developer gets instant feedback.

The 13 Rules That Keep Architecture Clean

The Weather Microservice enforces 13 distinct architectural rules. Let's look at the most important ones beyond layer dependencies:

Rule: No Field Injection

@Test
void fieldsShouldNotBeAutowired() {
    ArchRule rule =
        noFields()
            .should()
            .beAnnotatedWith(org.springframework.beans.factory.annotation.Autowired.class)
            .because("Field injection is discouraged, use constructor injection instead");
    rule.check(importedClasses);
}

This ensures nobody sneaks in a @Autowired field annotation. Constructor injection or nothing.

Rule: Naming Conventions

@Test
void controllersShouldBeNamedCorrectly() {
    ArchRule rule =
        classes()
            .that().resideInAPackage("..controller..")
            .and().areAnnotatedWith(RestController.class)
            .should().haveSimpleNameEndingWith("Controller");
    rule.check(importedClasses);
}

@Test
void servicesShouldBeNamedCorrectly() {
    ArchRule rule =
        classes()
            .that().resideInAPackage("..service..")
            .and().areAnnotatedWith(Service.class)
            .should().haveSimpleNameEndingWith("Service");
    rule.check(importedClasses);
}

Naming conventions aren't just style — they're a navigational aid. When you see WeatherService, you know it's in the service package. When you see LocationController, you know it handles HTTP requests. No guessing.

Rule: Services Must Use Final Fields

@Test
void servicesShouldUseConstructorInjection() {
    ArchRule rule =
        classes()
            .that().resideInAPackage("..service..")
            .and().areAnnotatedWith(Service.class)
            .should().haveOnlyFinalFields()
            .because("Services should use constructor injection with final fields");
    rule.check(importedClasses);
}

This goes beyond banning @Autowired — it ensures all fields are final, which means they must be set in the constructor. No mutable state in services.

Rule: Entities Must Live in the Model Package

@Test
void entitiesShouldBeInModelPackage() {
    ArchRule rule =
        classes()
            .that().areAnnotatedWith(jakarta.persistence.Entity.class)
            .should().resideInAPackage("..model..");
    rule.check(importedClasses);
}

Rule: Repositories Must Be Interfaces

@Test
void repositoriesShouldBeInterfaces() {
    ArchRule rule =
        classes()
            .that().resideInAPackage("..repository..")
            .should().beInterfaces();
    rule.check(importedClasses);
}

Spring Data JPA generates implementations at runtime. If someone accidentally writes a concrete repository class, this catches it.

Rule: Configuration Classes Must Be Properly Annotated

@Test
void configurationClassesShouldBeAnnotatedProperly() {
    ArchRule rule =
        classes()
            .that().resideInAPackage("..config..")
            .and().areNotAnonymousClasses()
            .and().areNotMemberClasses()
            .and().areNotInterfaces()
            .should().beAnnotatedWith(Configuration.class)
            .orShould().beAnnotatedWith(Component.class);
    rule.check(importedClasses);
}

📦 The Package Structure

Here's the actual package layout of the Weather Microservice:

com.weatherspring/
+-- annotation/        # Custom annotations (@Auditable, @CacheEvictingOperation)
+-- client/            # External API clients (WeatherApiClient)
+-- config/            # Spring configuration (Security, Cache, Async, OpenAPI)
+-- controller/        # REST controllers (Weather, Location, Forecast, Async)
+-- dto/               # Data Transfer Objects (records)
|   +-- external/      # External API response models
+-- exception/         # Exception hierarchy (sealed class + handlers)
+-- listener/          # JPA entity listeners
+-- mapper/            # Entity ↔ DTO converters
+-- model/             # JPA entities (Location, WeatherRecord, ForecastRecord)
+-- repository/        # Spring Data JPA interfaces
+-- service/           # Business logic
+-- util/              # Utility classes
+-- validation/        # Custom validators and constants

Each package has a clear purpose. There's no utils dumping ground with 47 unrelated classes. No common package that everything depends on. No helper classes that nobody can explain.

🤔 Why not use a feature-based package structure? For a microservice this size, feature-based packaging (grouping by domain entity) adds indirection without much benefit. The layered approach keeps the codebase predictable — you always know where to find a controller, a service, or a repository. For larger applications with many bounded contexts, feature-based packaging becomes more appropriate.


🔄 Putting It All Together: The Request Flow

Let's trace a complete request through the layers to see how they work together:

GET /api/weather/current?location=London&save=true

  1. WeatherController.getCurrentWeather()
     +- Validates: @NotBlank location ✓
     +- Delegates to: weatherService.getCurrentWeather("London", true)

  2. WeatherService.getCurrentWeather()
     +- Checks cache: @Cacheable key="weather:byName:London"
     +- Cache miss → calls: weatherApiClient.getCurrentWeather("London")
     +- saveToDatabase=true → calls: saveWeatherRecord(apiResponse)
     |   +- locationService.findOrCreateLocation("London", "UK", ...)
     |   +- weatherRecordRepository.save(weatherRecord)
     +- Returns: weatherMapper.toDtoFromApi(apiResponse)

  3. WeatherMapper.toDtoFromApi()
     +- Extracts fields from API response
     +- Parses timestamp from "yyyy-MM-dd HH:mm" format
     +- Returns: new WeatherDto(null, null, "London", 15.5, ...)

  4. Back to WeatherController
     +- Returns: ResponseEntity.ok(weatherDto) → HTTP 200

Each layer touches only its neighbors. The controller doesn't know about the API client. The mapper doesn't know about caching. The repository doesn't know about HTTP. Clean separation at every level.


✅ The Architecture Checklist

Before moving on, let's summarize the key takeaways as an actionable checklist:

  • [ ] Controllers are thin — They validate input, delegate to services, and format responses
  • [ ] Services own business logic — Transactions, caching, orchestration live here
  • [ ] Mappers isolate transformation — Entity ↔ DTO conversion happens in a dedicated layer
  • [ ] DTOs are immutable — Java records with no behavior, just data
  • [ ] Dependencies flow downward — No circular or upward dependencies
  • [ ] Constructor injection everywhere — All fields final, no @Autowired on fields
  • [ ] ArchUnit enforces the rules — Violations break the build
  • [ ] Naming conventions are consistent*Controller, *Service, *Repository, *Mapper
  • [ ] Packages map to layers — Predictable location for every class

🎓 Conclusion: Architecture is a Discipline, Not a Diagram

Here's what we learned in this first article:

  1. Layered architecture separates concerns into Controller, Service, Mapper, and Repository layers — each with a single responsibility
  2. The SLICED framework (Separation, Layered dependencies, Interface contracts, Constructor injection, Enforced boundaries, Directional flow) provides six guiding principles
  3. Thin controllers handle HTTP concerns only — validation, routing, and response formatting
  4. Service layers orchestrate business logic — transactions, caching, and coordination between components
  5. Java records as DTOs provide immutability and clarity without boilerplate
  6. Constructor injection with @RequiredArgsConstructor makes dependencies explicit and testable
  7. ArchUnit tests enforce architectural rules automatically — 13 rules that run on every build
  8. Package structure matters — predictable organization makes navigation intuitive

Architecture isn't something you draw on a whiteboard once and forget. It's a living discipline enforced by code, validated by tests, and maintained by the team. The Weather Microservice puts its architecture where its mouth is — in the test suite.

Coming Next Week:
Part 2: Spring Boot Alchemy - Turning Configuration into a Running Service ⚙️


📚 Series Progress

✅ Part 1: The Blueprint Before the Build ← You just finished this!
⬜ Part 2: Spring Boot Alchemy
⬜ Part 3: REST Assured
⬜ Part 4: The Data Foundation
⬜ Part 5: When the World Breaks
⬜ Part 6: Cache Me If You Can
⬜ Part 7: Guarding the Gates
⬜ Part 8: Fail Gracefully
⬜ Part 9: 10,000 Threads and a Dream
⬜ Part 10: Can You See Me Now?
⬜ Part 11: Trust, But Verify
⬜ Part 12: Ship It
⬜ Part 13: To Production and Beyond


Happy coding, and remember — the best architecture is the one that makes 3 AM debugging sessions shorter.


Robert Marcel Saveanu

Robert Marcel Saveanu

Software engineer with 15 years in testing, architecture, and the art of surviving corporate dysfunction. Writing about code, quality, and the humans behind both.

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to Codyssey.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.