Skip to main content

Command Palette

Search for a command to run...

Devlog: User Registration Domain Refactoring

The “DTO Leak” That Bugged Me

Published
8 min readView as Markdown
Devlog: User Registration Domain Refactoring

Date: 2026-01-20
PRs: #223, #224, #225, #226, #227
Theme: Domain-Driven Design · Command Pattern · DTO Refinement
Author: leodvincci
Branch: refactor/user-registration-domainmain
Window: Jan 20, 2026 (18:46–22:36) — ~3h 44m


Context: The “DTO Leak” That Bugged Me

My user registration module needed some architectural tightening.

The core issue was simple (and painfully common): my service layer was operating directly on web DTOs. That meant my domain logic was coupled to my API contract—exactly the kind of boundary leak that feels fine… until the day you want to evolve your API, add a new interface (CLI, batch job), or test the domain without HTTP baggage.

So today’s mission:
Keep DTOs at the boundary. Let the domain speak its own language.

That language is:

  • Commands (what we ask the domain to do)

  • Results (what the domain returns, intentionally shaped)

  • Entities (domain internals, not an API output format)

This refactor shipped as five PRs, merged in small, safe steps, with 13 commits total (including merge commits), all within about four hours.


The Big Idea

Before:

  • Controller accepts DTO

  • Service takes DTO (domain depends on web)

  • Service returns Entity (web sees internals)

After:

  • Controller accepts Request DTO

  • Controller maps DTO → Command

  • Service takes Command (domain language)

  • Service returns Result (focused contract)

  • Controller maps Result → Response DTO

The mapper becomes a translator and a guardrail—an anti-corruption layer between the web and the domain.


Phase 1: Introduce the Command Pattern (PR #223)

Commit: 4c1cb4a — Add UserRegistrationRequestCommand

Added

  • UserRegistrationRequestCommand record in the domain layer

  • Mapper methods in UserRegistrationMapper

Modified

  • UserRegistrationService now accepts a command instead of a DTO

  • UserRegistrationController now depends on the mapper

Files changed: 4 files, +25 / -6
PR #223 merged: Tue Jan 20 18:52:17 2026

What Changed (The Leak Gets Plugged)

Before: service depended on a web DTO.

public class UserRegistrationService {
    public UserEntity register(UserRegistrationRequestDTO dto) {
        // Service directly depends on web DTO
    }
}

After: service depends on a domain command.

public class UserRegistrationService {
    public UserEntity register(UserRegistrationRequestCommand command) {
        // Service works with domain command
    }
}

And the controller became responsible for translating boundary → domain:

@RestController
public class UserRegistrationController {
    private final UserRegistrationMapper mapper;

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody UserRegistrationRequestDTO dto) {
        UserRegistrationRequestCommand command = mapper.toCommand(dto);
        UserEntity result = service.register(command);
        return ResponseEntity.ok(mapper.toDTO(result));
    }
}

Why This Matters

  • Decoupling: domain no longer knows what the API looks like

  • Testability: service can be unit-tested without DTOs

  • Flexibility: DTOs can evolve independently of domain logic


Phase 2: DTO Naming Consistency (PR #224)

Commit: 2363ded — Rename Registration DTOs

Renamed

  • UserRegistrationRequestDTORegisterUserRequestDTO

  • UserRegistrationResponseDTORegisterUserResponseDTO

Modified

  • Mapper method signatures

  • Controller references

Files changed: 4 files, +15 / -15
PR #224 merged: Tue Jan 20 20:29:58 2026

The Naming Upgrade

The old names were verbose and didn’t match the patterns I’m using elsewhere. The new naming follows a verb-first convention:

OldNewPattern
UserRegistrationRequestDTORegisterUserRequestDTO[Action][Resource][Type]
UserRegistrationResponseDTORegisterUserResponseDTO[Action][Resource][Type]

This lines up with command naming like CreateBookCommand, CheckoutBookCommand, etc.

Naming is architecture. It shapes what code wants to become.


Phase 3: Refine Commands + Add Result Objects (PR #225)

Commit: dc9464c — Replace Command and Add Result Object

Added

  • RegisterUserCommand (replacing UserRegistrationRequestCommand)

  • RegisterUserResult record to encapsulate service response

Renamed

  • UserRegistrationMapperAppUserMapper

Modified

  • Service now returns RegisterUserResult

  • Response DTO enhanced to include user ID

Files changed: 7 files, +31 / -22
PR #225 merged: Tue Jan 20 22:03:50 2026

The Full Flow (Now Clean)

Request DTO → Command → Service → Result → Response DTO

Web Layer   Domain Layer       Domain Layer   Web Layer
   DTO   →    Command   →  Service  → Result  →  DTO

Before vs After

Before: Service returned the entity directly.

UserEntity result = service.register(command);
RegisterUserResponseDTO response = mapper.toResponseDTO(result);

After: Service returns a result object.

RegisterUserResult result = service.register(command);
RegisterUserResponseDTO response = mapper.toResponseDTO(result);

Why Result Objects?

Because returning entities is like giving your API consumers a “free tour” of your internals.

ConcernWithout ResultWith Result
Return valueEntityFocused result
CouplingExposes full entityHides internals
EvolutionEntity changes can break APIResult buffers change
ClarityPurpose unclearIntent explicit

The result record contains only what the outside world needs:

public record RegisterUserResult(
    Long userId,
    String username,
    String email
) {}

Why Rename the Mapper?

UserRegistrationMapper became AppUserMapper because it maps across:

  • DTO ↔ Command

  • Result ↔ Response DTO

  • Command ↔ Entity (domain construction)

“Registration” was too narrow. “AppUser” is the actual domain concept.


Phase 3.1: Clean Up Dead Mapper Code

Commit: f030d9c — Remove Unused Mapper Method

Removed a dead method: toDTO(RegisterUserCommand).

Commands flow into the domain. They don’t flow back out.

✅ Correct flow: DTO → Command → Service
❌ Wrong flow: Command → DTO (why would we ever do that?)

Dead code removal isn’t glamorous, but it keeps the architecture legible.


Phase 3.2: Test Enhancements

Commit: 209e53d — Formatting and Test Enhancement

Updated the controller test to assert more than “it worked.”

@Test
void testRegisterUser() {
    // ... existing setup ...

    assertNotNull(response.getUserId());
    assertEquals("testuser", response.getUsername());
    assertEquals("test@example.com", response.getEmail());
}

Now the test verifies the full mapping chain and the improved response contract.


Phase 4: Complete the Mapping Chain (PR #226)

Commit: e0a007b — Add toResponseDTO(RegisterUserResult)

Added

  • toResponseDTO(RegisterUserResult) method in AppUserMapper

Modified

  • Controller now uses mapper for all conversions

  • Added structured logging

Files changed: 2 files, +8 / -3
PR #226 merged: Tue Jan 20 22:21:27 2026

This removed manual response construction in the controller and centralized transformation logic inside the mapper—exactly where it belongs.

Logging Enhancements

Added logs at key points:

log.info("Starting user registration for username: {}", command.username());
RegisterUserResult result = service.register(command);
log.info("User registered successfully with ID: {}", result.userId());

Phase 5: Final Polish (PR #227)

Commit: 6ca363e — Controller line formatting

Minor readability tweak.

Commit: e86fab4 — BCryptPasswordEncoder variable rename

Renamed encoder variable for consistency:

Before

private final BCryptPasswordEncoder passwordEncoder;

After

private final BCryptPasswordEncoder bCryptPasswordEncoder;

PR #227 merged: Tue Jan 20 22:36:24 2026

Tiny change, but consistency compounds.


Architecture Evolution

Before Refactoring

Controller (DTO) → Service (DTO) → Entity returned

Problems:

  • Domain coupled to web DTOs

  • Entity exposed to the web layer

  • API changes risk cascading into domain changes

  • Harder to test domain logic cleanly

After Refactoring

Controller (RequestDTO)
  → AppUserMapper → RegisterUserCommand
    → Service → RegisterUserResult
      → AppUserMapper → ResponseDTO

Benefits:

  • ✅ Domain independent of web concerns

  • ✅ Clear boundary contracts (Commands/Results)

  • ✅ Entity stays internal

  • ✅ Mapper protects both sides

  • ✅ API can evolve without rewiring domain logic


Files Modified Summary

Total: 10 unique files touched, ~80 net lines added


Pull Request Timeline

PRTitleMerged
#223Initial Command Introduction18:52:17
#224DTO Naming Consistency20:29:58
#225Formatting & Test Enhancement22:03:50
#226Mapper Method Addition22:21:27
#227Final Variable Rename22:36:24

Time span: ~3h 44m (18:52 → 22:36)
Merge conflicts: 0 (blessed timeline)


Pattern Recognition (What This Refactor Demonstrates)

  • Command Pattern: encapsulate “do this” as a domain object

  • Result Object Pattern: return focused, stable data instead of entities

  • Anti-Corruption Layer: mapper prevents boundary bleed

  • Incremental Refactoring: small PRs, low risk, always mergeable

  • Naming Consistency: verb-first naming aligns code with intent


Reflection: DTOs Belong at Boundaries

This refactor was “small” in terms of code, but big in terms of clarity.

The key insight I’m taking forward:

DTOs should stay at the edges.
The domain should speak its own language—Commands, Results, Entities—not the language of HTTP.

Also: tiny records with focused data are absurdly powerful.
RegisterUserCommand and RegisterUserResult are simple, but they turn implicit coupling into explicit contracts.

And finally: naming matters more than it seems.
RegisterUserRequestDTO is not just prettier than UserRegistrationRequestDTO. It carries intent. It matches the rest of my command-oriented design. It makes the system feel coherent.


Next Steps

Immediate:

  • Apply the same command/result pattern to other user operations

  • Add validation annotations to command objects

  • Introduce error result objects for failure cases

Future:

  • Extract registration into a dedicated bounded context

  • Emit domain events on successful registration

  • Add audit logging and confirmation workflows

  • Integration tests for full registration flow


Metrics (Because We Measure What We Respect)

  • Pull Requests: 5 (all merged)

  • Commits: 13 total (9 functional + 4 merges)

  • Files Modified: 10

  • Net Lines Changed: ~80

  • Time Span: ~4 hours

  • Merge Conflicts: 0


That’s today’s devlog: a boundary leak patched, a domain language clarified, and a mapper promoted from “utility class” to “architectural bouncer.”

Onward.