Saltar al contenido
Mis Guidelines
Guías
En esta guía (5 elementos)

Data Flow & Transformations

Data Flow & Transformations

Project-agnostic catalog. Part of the Diagram Catalog. Covers: full request lifecycle, commands vs domain entities, transformation chain.

Replace com.example.platform / platform-service placeholders with your project’s actual package and service name when applying patterns.


6. Data Flow & Transformations

6.1 Complete Request Flow: Create Organization

┌───────────────────────────────────────────────────────────────────┐
│ STEP 1: HTTP REQUEST │
│ │
│ Client → POST /api/v1/organizations │
│ Content-Type: application/json │
│ { │
│ "name": "Tech Innovators Inc.", │
│ "contactName": "Alice", │
│ "contactLastName": "Johnson", │
│ "contactEmail": "alice.johnson@techinnovators.com", │
│ "address": "456 Innovation Blvd" │
│ } │
└────────────────────────┬──────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ STEP 2: CONTROLLER (Infrastructure Layer) │
│ File: OrganizationController.kt │
│ │
│ @PostMapping │
│ fun createOrganization( │
│ @Validated @RequestBody request: CreateOrganizationRequest │
│ ): ResponseEntity<CreatedOrganizationResponse> { │
│ // 2.1: Bean Validation executes (@NotBlank, @Email, etc.) │
│ // 2.2: Convert REST DTO → Command │
│ val command = request.toCreateOrganizationCommand() │
│ │
│ // 2.3: Invoke inbound port │
│ val organization = createOrganizationPort.execute(command) │
│ │
│ // 2.4: Convert Domain → Response DTO │
│ return ResponseEntity.created(...) │
│ .body(organization.toCreatedOrganizationResponse()) │
│ } │
└────────────────────────┬──────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ STEP 3: USE CASE (Application Layer) │
│ File: CreateOrganizationUseCase.kt │
│ │
│ class CreateOrganizationUseCase( │
│ private val repository: OrganizationRepositoryPort, │
│ private val transaction: TransactionPort<Organization>, │
│ private val outbox: OutboxRepositoryPort, // SDK port │
│ private val events: OutboxEventFactory, // SDK fact │
│ ) : CreateOrganizationPort { │
│ │
│ override fun execute( │
│ command: CreateOrganizationCommand │
│ ): Organization = transaction.run { │
│ // 3.1: Command → Domain Entity │
│ val organization = command.toOrganization() │
│ │
│ // 3.2: Persist via repository port (same TX) │
│ val saved = repository.save(organization) │
│ │
│ // 3.3: Enqueue OUTBOX row (same TX) — NO broker │
│ outbox.save(events.create( │
│ topic = "platform.organizations", │
│ traceId = command.traceId, │
│ aggregate = saved.id.toString(), │
│ eventName = "OrganizationCreated", │
│ eventType = EventTypes.EVENT, │
│ payload = OrgCreatedPayload.from(saved), │
│ )) │
│ saved │
│ } │
│ } │
└────────────────────────┬──────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ STEP 4: TRANSACTION (Infrastructure → Application Port) │
│ File: JdbcTransaction.kt │
│ │
│ @Component │
│ class JdbcTransaction<R> : TransactionPort<R> { │
│ │
│ @Transactional(propagation = Propagation.REQUIRED) │
│ override fun run( │
│ callback: TransactionCallbackPort<R> │
│ ): R { │
│ try { │
│ log.debug("[START] transaction") │
│ val result = callback.call() │
│ log.debug("[END] transaction") │
│ return result │
│ } catch (ex: Exception) { │
│ log.error("Transaction failed", ex) │
│ throw ex │
│ } │
│ } │
│ } │
└────────────────────────┬──────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ STEP 5: REPOSITORY (Infrastructure → Application Port) │
│ File: OrganizationRepositoryAdapter.kt │
│ │
│ @Component │
│ class OrganizationRepositoryAdapter( │
│ private val organizationJdbcRepository: OrganizationJdbcRepo │
│ ) : OrganizationRepositoryPort { │
│ │
│ override fun save(organization: Organization): Organization {│
│ // 5.1: Domain Entity → Database Entity │
│ val entity = organization.toDatabaseEntity() │
│ │
│ // 5.2: Save to database (Spring Data JDBC) │
│ val saved = organizationJdbcRepository.save(entity) │
│ │
│ // 5.3: Database Entity → Domain Entity │
│ return saved.toDomainModel() │
│ } │
│ } │
└────────────────────────┬──────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ STEP 6: HTTP RESPONSE (broker NOT yet contacted) │
│ │
│ HTTP 201 Created │
│ Location: /api/v1/organizations/{uuid} │
│ { │
│ "id": "550e8400-e29b-41d4-a716-446655440000", │
│ "status": "PENDING_APPROVAL" │
│ } │
└───────────────────────────────────────────────────────────────────┘

Asynchronous lane: SDK dispatcher → broker (decoupled from request)

every outbox.task.fixed-delay (default 5s)
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ OUTBOX SCHEDULER (codehuntersio-sdk-event-messaging) │
│ │
│ @Scheduled(fixedDelayString = "${outbox.task.fixed-delay:5000}") │
│ @SchedulerLock( │
│ name = "outbox-${spring.application.name}", │
│ lockAtMostFor = "...", lockAtLeastFor = "..." │
│ ) │
│ fun tick() = sendOutboxMessagesPort.execute() │
└────────────────────────┬──────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ SendOutboxMessagesUseCase (SDK) │
│ │
│ // A.1 — atomic claim │
│ val batch = outbox.claimPendingMessages( │
│ origin, batchSize, instanceId, claimTtl │
│ ) │
│ // PENDING || stale IN_FLIGHT → IN_FLIGHT, stamp claimed_by │
│ │
│ for (msg in batch) { │
│ try { │
│ // A.2 — publish via broker-agnostic port │
│ messagePublisher.publish(msg.topic, msg.routingKey, msg.message)│
│ msg.markPublished() │
│ } catch (e) { │
│ // A.3 — backoff or fail │
│ msg.attempts++ │
│ if (msg.attempts >= maxAttempts) msg.markFailed(e) │
│ else msg.scheduleRetry(computeBackoffMs(msg.attempts)) │
│ } │
│ outbox.update(msg) │
│ } │
└────────────────────────┬──────────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ MessagePublisherPort impl (Rabbit by default, Kafka by override) │
│ │
│ RabbitMessagePublisherAdapter.publish(topic, routingKey, payload)│
│ → rabbitTemplate.send(...) │
│ │
│ OR (override the bean) │
│ │
│ KafkaMessagePublisherAdapter.publish(topic, routingKey, payload) │
│ → kafkaTemplate.send(topic, routingKey, payload).get(5, TimeUnit.SECONDS) │
└───────────────────────────────────────────────────────────────────┘

Two lanes, one source of truth: the synchronous lane commits the outbox row; the asynchronous lane drains it. The use case never touches the broker; the dispatcher never touches the domain.

6.2 Commands vs Domain Entities: Comparison

AspectCommandDomain Entity
PurposeRepresent user intentionRepresent business state
LocationApplication LayerDomain Layer
Properties5 (input only)14 (complete state)
IdentityNo id fieldHas id (UUID)
StatusNo status fieldHas status enum
Audit FieldsNonecreatedAt, updatedAt, createdBy, etc.
ValidationIn REST DTO layerDomain invariants
MutabilityImmutable (val)Immutable (regular class, private ctor)
Business LogicNoneMethods for state transitions
LifecycleRequest-scopedPersisted, full lifecycle
Version ControlN/AOptimistic locking via version
Soft DeleteN/Adeleted, deletedAt fields

6.3 Why Use Commands Instead of Domain Entities?

✅ Advantages of Commands

  1. Separation of Concerns

    • Commands represent input (what user wants)
    • Entities represent state (what system has)
    • Clear boundary between user intent and domain state
  2. Framework Independence

    • Commands are pure Kotlin data classes
    • No dependency on persistence, validation frameworks
    • Easy to test in isolation
  3. Explicit Contracts

    • Inbound ports define clear contracts
    • Changes to input structure don’t affect domain
    • Versioning flexibility (v1 command → same domain entity)
  4. Validation Separation

    • REST layer: Syntactic validation (@NotBlank, @Email)
    • Command: Pure data transfer
    • Domain: Business invariants validation
  5. Evolution Support

    • Add/remove fields in commands without touching domain
    • Support multiple commands creating same entity type
    • API versioning without domain changes

❌ What Happens Without Commands?

// ❌ BAD: Using Domain Entity directly in controller
@PostMapping
fun createOrganization(
@RequestBody organization: Organization // ANTI-PATTERN!
): ResponseEntity<Organization> {
// Problems:
// 1. Client can set id, createdAt, version → Security risk
// 2. Domain entity exposed to HTTP layer → Tight coupling
// 3. No clear intent → Is this create or update?
// 4. Validation mixed with domain logic
// 5. Can't evolve API without changing domain
return ResponseEntity.ok(
createOrganizationPort.execute(organization)
)
}

Issues:

  1. Security: Client can manipulate id, version, createdAt
  2. Coupling: Domain entity tied to HTTP contract
  3. Unclear Intent: Same entity for create/update/patch
  4. Validation Confusion: Where to validate? Controller? Domain?
  5. Evolution Difficulty: Can’t change API without domain impact

✅ GOOD: Using Commands

// ✅ GOOD: Using Command pattern
@PostMapping
fun createOrganization(
@Validated @RequestBody request: CreateOrganizationRequest
): ResponseEntity<CreatedOrganizationResponse> {
// Benefits:
// 1. Only accepts user-provided fields
// 2. Domain entity created internally with system fields
// 3. Clear intent: "create" operation
// 4. Validation in REST layer, business rules in domain
// 5. API can evolve independently
val command = request.toCreateOrganizationCommand()
val organization = createOrganizationPort.execute(command)
return ResponseEntity.created(...)
.body(organization.toCreatedOrganizationResponse())
}

Benefits:

  1. Security: System controls id, version, timestamps
  2. Decoupling: REST, Command, Domain are independent
  3. Clear Intent: Explicit “create” command
  4. Proper Validation: Syntax in REST, business in domain
  5. Easy Evolution: Change request/response without domain impact