Clean architecture

One request

Save a note from the wire payload, through the use case and the domain, to the row that is stored.

clean-architecturerequest

This is SaveNote from the caller's payload to the row, and back. The same steps apply in a layer-first tree and a feature-first tree. Only the paths change.

The call

A client submits:

text
{ "title": "Market", "body": "Buy oat milk" }

That JSON is the wire model. It has no id and no timestamp.

Presentation

The entry point does not construct a repository. It maps, calls, and maps again.

text
save(request):
  try:
    command = SaveNoteCommand(
      title: request.payload.title,
      body: request.payload.body,
    )
    result = saveNote.execute(command)
    return 201, SaveNoteResponse(
      id: result.note.id,
      title: result.note.title,
      body: result.note.body,
      createdAt: result.note.createdAt,
    )
  catch InvalidTitle:
    return 400, field error on title
  catch storage failure:
    return 500

saveNote was built by the composition root for this request.

Application

text
SaveNote.execute(command):
  note = Note.create(command.title, command.body, clock.now())
  saved = notes.save(note)
  return SaveNoteResult(saved)

No status code, no JSON, no table name. notes is a NoteRepository. In production it is SqlNoteRepository. In a unit test it is a fake. This function does not know which.

Domain

text
Note.create(title, body, now):
  if title is blank:
    fail InvalidTitle
  return Note(newId(), title, body, now)

For "Market" this returns a note. For "" it fails, notes.save is never called, and presentation maps InvalidTitle to the field error.

Infrastructure

text
SqlNoteRepository.save(note):
  insert into notes (
    id, title, body, created_at
  ) values (
    note.id, note.title, note.body, note.createdAt
  )
  return note

The insert is the persistence model. If the driver reports a timeout, that failure propagates as an infrastructure failure. The repository does not translate it into InvalidTitle.

What came back

text
201
{
  "id": "…",
  "title": "Market",
  "body": "Buy oat milk",
  "createdAt": "…"
}

The response is a fourth shape only in the sense that it is the wire model on the way out. It is still presentation. The entity gained an id and a timestamp the client did not send. The mapper is the only place that copies those fields onto the response.

A blank title

text
{ "title": "", "body": "Buy oat milk" }

Note.create fails. The use case ends. The repository is not called. The client receives a field error. The table is unchanged. That is the dependency rule at runtime: the inner ring can refuse the outer ring's data, and the outer ring cannot write anyway, because the write lives behind a port the use case never reached.

Where the files sit

Layer-first, the pieces are presentation/notes, application/notes, domain/notes, infrastructure/notes, and composition. Feature-first, the same five roles sit under features/notes/, and composition stays shared. Hybrid, the use case stays in horizontal application/notes even when the entry point lives in presentation/notes. Moving the use case into the feature folder would make the workflow depend on the edge.