I build the same nine files for every entity, in the same order, every time. Not because creativity is wasted on CRUD — because it isn't. The interesting engineering in a backend is the eligibility matcher, the pricing rule, the state machine. The plumbing that gets a domain object safely into a database and back out over HTTP should be boring, consistent, and fast to produce, so the time and judgment go where they're worth something.

This is the layout, worked through against a real entity — User — from a production Spring Boot codebase.

The nine layers

One Java domain object in, nine files out, always in this order:

  1. Domain — a plain POJO, no framework annotations, no persistence concerns
  2. Request DTOs — one for create, one for update, each with its own validation rules
  3. Response DTO — only what the client should see
  4. Entity — the JPA-mapped shape of the same object
  5. Mapper — domain ↔ entity, nothing else
  6. Repository — Spring Data, plus the finders this entity actually needs
  7. DB service — CRUD against the repository, one failure-handling pattern
  8. Business service — validation, transactions, business rules
  9. Controller + converter — HTTP in, DTOs out, wired to the business service
The nine layers, in generation order A left-to-right pipeline of nine boxes: Domain, Request DTOs, Response DTO, Entity, Mapper, Repository, DB service, Business service, Controller, each labeled with its layer number. 1 Domain plain POJO 2 Request DTOs create, update 3 Response DTO only what shows 4 Entity JPA-mapped 5 Mapper domain ↔ entity 6 Repository Spring Data 7 DB service CRUD, failures 8 Business service rules, transactions 9 Controller HTTP in/out One domain object in, nine files out — always in this order.

Every layer has exactly one job. A bug in validation is a request-DTO problem. A bug in a query is a repository problem. Nothing is guessing at two responsibilities at once.

Domain: the thing itself

@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class User extends BaseDomain {
    private String username;
    private String password;
    private String email;
    private String role = "USER";
}

BaseDomain carries id, extid, createdAt, updatedAt, deletedAt, and active — audit and identity fields every entity needs, written once. This class only states what makes a User a User. No JPA, no HTTP, no validation. It's the one class every other layer converts to or from, and the only one a reviewer needs to read to know what the entity means.

Request and response: never the same shape

public class RequestUserCreate extends BaseRequest {
    @NotEmpty(message = "The username is required.")
    @Size(max = 50, message = "The username must be at most 50 characters.")
    private String username;

    @NotEmpty(message = "The password is required.")
    @Size(max = 255, message = "The password must be at most 255 characters.")
    private String password;

    @Size(max = 100, message = "The email must be at most 100 characters.")
    private String email;

    @Size(max = 20, message = "The role must be at most 20 characters.")
    private String role = "USER";
}

Create and update are different DTOs on purpose — update makes every field optional (no @NotEmpty), because a PUT/PATCH shouldn't have to resend the whole object to change one field. The response DTO is narrower still:

@Data
@Builder
public class ResponseUser {
    private String extid;
    private String username;
    private String email;
    private String role;
}

No password. Not because someone remembered to strip it — because the response type never had a field to leak in the first place. That's a design decision expressed as a compiler guarantee instead of a code-review checklist item.

Entity: the database's opinion of the object

@Entity
@Table(name = "user")
public class UserDb extends BaseDb {
    @Column(name = "username", length = 50, nullable = false, unique = true)
    private String username;

    @Column(name = "password", length = 255, nullable = false)
    private String password;

    @Column(name = "email", length = 100, nullable = true)
    private String email;

    @Column(name = "role", length = 20, nullable = false)
    private String role = "USER";
}

Same fields as User, deliberately duplicated rather than shared, because a domain object and a persistence mapping have different reasons to change. The unique constraint on username lives here and in the matching Liquibase changeset — not in the domain layer, which shouldn't know that a database enforces it.

A mapper is the only thing allowed to convert between the two:

@Component
public class UserMapper {
    private final ModelMapper modelMapper = new ModelMapper();

    public User toModel(UserDb item) { return modelMapper.map(item, User.class); }
    public UserDb toDb(User item) { return modelMapper.map(item, UserDb.class); }

    public List<User> toModelList(List<UserDb> items) {
        if (items == null) return List.of();
        return items.stream().map(this::toModel).toList();
    }
}

The null check on the list method is there because an earlier version of this pattern returned null for a null input, and every caller had to remember to guard against it. Now the guarantee is in one place: this always returns a list.

DB service: one failure pattern, applied every time

public User create(@NonNull User item) {
    String extid = UUID.randomUUID().toString();
    LocalDateTime now = LocalDateTime.now();
    try {
        UserDb record = mapper.toDb(item);
        record.setExtid(extid);
        record.setCreatedAt(now);
        record.setUpdatedAt(now);
        record.setActive(ActiveEnum.ACTIVE);
        UserDb saved = repository.save(record);
        log.info(getCreatedMessage(extid));
        return mapper.toModel(saved);
    } catch (Exception e) {
        handleException("create", extid, e);
        return null; // unreachable — handleException always throws
    }
}

Every create generates its own external ID (extid) — a UUID never exposed as the database's auto-increment id — sets audit timestamps, and wraps the save in the same try/catch/log/rethrow shape every other entity uses. Delete is soft: setDeletedAt plus ActiveEnum.INACTIVE, never a SQL DELETE. Nothing here is entity-specific except the field names; a reviewer who's read one DbService has read all of them.

The read side follows the same shape, and it's where pagination actually lives — page and sort come in from the caller, but nothing here decides whether they're reasonable:

public Page<User> findAll(Pageable pageable) {
    Page<UserDb> page = repository.findByActive(ActiveEnum.ACTIVE, pageable);
    log.info(getFoundMessageByType("findAll(pageable)", (int) page.getTotalElements()));
    return page.map(mapper::toModel);
}

Deciding what's reasonable — a page-size cap, a sort-field whitelist — isn't this layer's job. That's next.

Business service: where the actual rules live

@Service
@Transactional(readOnly = true)
public class UserService extends BaseService {

    private static final int MAX_PAGE_SIZE = 100;
    private static final Set<String> ALLOWED_SORT_FIELDS =
        Set.of("username", "email", "role", "createdAt", "updatedAt");

    @Transactional
    public User create(User item) {
        requireNonNull(item, "User");
        try {
            return dbService.create(item.getUsername(), encodeIfPresent(item.getPassword()),
                item.getEmail(), item.getRole());
        } catch (Exception e) {
            throw new ServiceException("Unable to create user", e);
        }
    }

    public Page<User> findAll(Pageable pageable, ActiveEnum activeEnum) {
        Pageable safe = enforceCapsAndWhitelist(pageable);
        return activeEnum == null ? dbService.findAll(safe) : dbService.findByActive(activeEnum, safe);
    }

    private Pageable enforceCapsAndWhitelist(Pageable pageable) {
        int size = Math.min(pageable.getPageSize(), MAX_PAGE_SIZE);
        Sort safeSort = pageable.getSort().stream()
            .filter(order -> ALLOWED_SORT_FIELDS.contains(order.getProperty()))
            .collect(() -> Sort.unsorted(), (acc, order) -> acc.and(Sort.by(order.getDirection(), order.getProperty())), Sort::and);
        return PageRequest.of(pageable.getPageNumber(), size, safeSort);
    }
    // update, delete, findByExtid, findByActive...
}

This is the layer that's allowed to know business rules — a page-size cap so a client can't request 100,000 rows in one call, a sort-field whitelist so ORDER BY can't be pointed at an arbitrary column, ResourceNotFoundException translated from "the DB service returned null."

The password hashing tells the same story a different way. It's one line — passwordEncoder.encode(...) before the value ever reaches dbService — but it's entity-specific business policy sitting in the layer whose job is entity-specific business policy, which is exactly why it was fixable in one place instead of at every call site:

/**
 * BCrypt-hashes a password before it reaches the database.
 *
 * Only AuthController.register hashed passwords; every write through this
 * service stored the raw string. A password changed through the API could
 * never log in again, because authentication compares against a BCrypt hash
 * and the plaintext sat in the database.
 *
 * Found on 2026-08-11 changing a password: the update returned 200 and the
 * new password then failed to authenticate.
 */
private String encodeIfPresent(String rawPassword) {
    return rawPassword == null || rawPassword.isBlank() ? null : passwordEncoder.encode(rawPassword);
}

That bug is the argument for the template as much as any diagram is. It wasn't caught by a missing test — it was caught by the fact that hashing has exactly one legitimate home once the layers are separated, so "is it in the right place" became a one-line diff instead of a hunt through every write path that touches a password.

Controller: HTTP, and nothing else

@RestController
@RequestMapping("/api/user")
public class UserController {
    @PostMapping
    public ResponseEntity<ResponseUser> create(@Valid @RequestBody RequestUserCreate request) {
        User created = userService.create(converter.toDomain(request));
        URI location = URI.create("/api/user/" + created.getExtid());
        return ResponseEntity.created(location).body(converter.toResponse(created));
    }

    @GetMapping
    public Page<ResponseUser> getAll(
            @PageableDefault(size = 20, sort = "username") Pageable pageable,
            @RequestParam(required = false) ActiveEnum active) {
        return userService.findAll(pageable, active).map(converter::toResponse);
    }

    @PutMapping("/{extid}")
    public ResponseUser update(@PathVariable String extid, @Valid @RequestBody RequestUserUpdate request) {
        converter.validateUpdateRequest(request);
        return converter.toResponse(userService.update(extid, converter.toDomain(request)));
    }
    // GET single, PATCH (delegates to update), DELETE (soft, 204/404)
}

A package-private converter class in the same file does the DTO↔domain conversion — not injected, not a separate service, because it's stateless and has no reason to be anywhere else. The controller's only job is: validate the shape of the request, call one service method, convert the result. If a controller method has an if statement that isn't about HTTP status codes, that logic is in the wrong layer.

Here's a create request end to end, across all five moving pieces — one call down through validation, hashing, and the insert, and one response back up:

A create-User request, call by call Five participants left to right: Client, Controller, Business service, DB service, Database. A request travels right through each as a POST, and a response travels back left as a 201 with the created user. Client Controller Business service DB service + repository Database POST /api/user @Valid request, converter → Domain service.create(user) bcrypt the password dbService.create(...) INSERT, extid + timestamps set saved row User (mapped from entity) User (password hash discarded by converter) 201 Created, ResponseUser body

Notice what the diagram makes visible that the code alone doesn't: the password hash travels one hop — business service to DB service to database — and never reaches the controller or the client. That's not a rule anyone has to remember to enforce; it's just what happens when ResponseUser has no password field to carry it in.

Why this is worth having as a template

The value isn't the pattern itself — plenty of Spring codebases land somewhere similar. It's that I generate it the same way every time, for every entity, so:

I use this as a literal generation template — I keep it as a written specification and apply it entity by entity, which is also why it holds up as documentation: the spec and the code can't drift, because the spec is what generated the code.

Questions

If you're building a Spring Boot backend and want to talk through whether this shape fits your domain — it doesn't fit everything, and I'll tell you where it doesn't — reach out.

jeb.seibel@yahoo.com