Skip to content

ADR-0020: Input/Result DTO Split by Usage Direction

Accepted Cross-Project Universal

Date: 2026-04-21

Context

The territories that follow the Action Class Architecture and FormRequest → DTO Flow rely on a single flat namespace — App\DataTransferObjects\ — for every DTO that crosses a layer boundary. The namespace sits at the Deptrac dependency graph as a leaf layer — zero dependencies on App\*, because FormRequest-produced DTOs must stay decoupled from Models so the HTTP boundary remains honest (no Eloquent internals leaking into request validation).

That constraint is sound for request DTOs (what Actions receive). It breaks down when an Action legitimately needs to return domain state as a DTO — a Collection<int, Order> plus truncation metadata, an Action's multi-field computation result that spans several Models, a pipeline harness's aggregated outcome. Three recurring workarounds have surfaced across territories:

  1. Flatten the Collection inside the Action — loop over the Collection<Model>, produce a list<SomePrimitiveDto> (loop 1), hand it to the Controller, which maps it into ResourceData (loop 2). The intermediate DTO is pure ceremony; every field is replicated in the final ResourceData. BIO's GetFamilySetCompletionAction ran this double-loop on every family-sets request.
  2. Ad-hoc sub-namespace by dependency content — create Result/ when a DTO happens to carry Models, keep everything else under the root. Kendo PR #901 did this first. A follow-up PR (#903) had to relocate 15 additional files that were named *ResultData but didn't happen to carry Models — the rule classified by consequence, not by purpose. Pure-primitive Action-return DTOs stayed mis-placed.
  3. Third DTO namespace as an escape valve — BIO had App\Data\ for "internal transfer slips" alongside App\DataTransferObjects\ for "request DTOs." Two leaf layers with no dependency allowance, both hitting the same Model-barrier problem.

The common thread: the boundary that matters is the Action, not the shape of what moves across it. A DTO is either something an Action receives or something an Action returns. Treating those as one namespace conflates two distinct concerns — boundary purity on the input side, and expressive return types on the output side.

Why classification by dependency content fails

Kendo's first attempt used the rule "does the DTO depend on Models?" — pure-primitive DTOs stayed under Input/, Model-carrying DTOs moved to Result/. The rule broke immediately for eight *ResultData files that happened to be pure-primitive today but were the declared return types of Actions. They sat under Input/ because the rule asked the wrong question. When the follow-up arch test ran on the corrected rule, it caught six additional files the first pass missed — none were named *ResultData, all were Action returns that the naming-based catalog had no way to see.

The correct rule asks "does the Action receive it or return it?" That question has a definite answer for every DTO, it is machine-checkable via reflection on Action::execute() signatures, and it aligns the namespace structure with the semantic layer the DTO serves.

Decision

DTOs MUST live under one of two sibling namespaces, classified by their role at the Action boundary:

  • App\DataTransferObjects\Input\<Domain>\ — DTOs that Actions receive. Sources include FormRequest toDto() output and Service-returned payloads consumed as Action parameters (e.g. third-party API deserialization feeding an upsert Action). Pure leaves — may depend on Enums only, never on Models.
  • App\DataTransferObjects\Result\<Domain>\ — DTOs that Actions return. May depend on Enums, Models, and Collection<Model>. The layer that unlocks Action return types expressive enough to carry domain state.

The classification rule: "Does the Action receive this DTO (→ Input) or return it (→ Result)?" Dependency content is a consequence, not the rule. A pure-primitive DTO can legitimately live under Result/ if it is an Action's declared return type.

Three architecture tests enforce the rule

  1. Action return-type DTOs live in Result/ — reflect over app/Actions/. If execute() returns a class resolving to App\DataTransferObjects\*, the class MUST resolve to App\DataTransferObjects\Result\*. Skips void, scalars, Enum, Model, Collection, Paginator, external types.
  2. Action parameter DTOs live in Input/ — reflect over app/Actions/. Any execute() parameter resolving to App\DataTransferObjects\* MUST resolve to App\DataTransferObjects\Input\*.
  3. FormRequest toDto() return types live in Input/ — reflect over app/Http/Requests/. If the class declares toDto(), the method MUST have an explicit return type declaration, and if that return type is a DTO, it MUST resolve to App\DataTransferObjects\Input\*. Missing return type is a failure with an explicit type-declaration message.

Deptrac enforces the secondary dependency constraint

Two layers, not one:

yaml
- name: InputDTOs
  collectors:
    - type: directory
      value: app/DataTransferObjects/Input/.*

- name: ResultDTOs
  collectors:
    - type: directory
      value: app/DataTransferObjects/Result/.*

InputDTOs must not depend on Models. ResultDTOs may. Consumer layers (Actions, Controllers, Services, Audit, FormRequests, Jobs, etc.) list whichever of the two they actually reach; the default surface is Actions → both, everyone else → one or the other per their role.

Pattern example — eliminating the double-loop

Before (BIO GetFamilySetCompletionAction pre-migration):

php
// Action returns list<FamilySetCompletionData> — Data class forbidden from carrying Models
return array_values($familySets->map(fn (FamilySet $fs) => new FamilySetCompletionData(
    familySetId: $fs->id,
    setNum:      $fs->set->set_num,   // flatten Model relation
    totalParts:  $totalParts,
    storedParts: $storedParts,
    percentage:  $percentage,
))->all());

// Controller re-maps list<FamilySetCompletionData> → list<FamilySetCompletionResourceData>
return array_map(FamilySetCompletionResourceData::from(...), $completionData);

After:

php
// Action returns Result DTO carrying Collection<FamilySet> + keyed counts array
return new FamilySetCompletionsResultData(
    familySets:     $familySets,
    countsBySetId:  $countsBySetId,
);

// ResourceData shapes the response in a single pass over the Collection
return FamilySetCompletionResourceData::collection($resultData);

One loop. The intermediate DTO is deleted. The ResultDTOs layer's Model-dependency allowance is what unlocks the rewrite.

Options Considered

OptionVerdictReason
Flat DataTransferObjects/ + allow Models everywhereRejectedRemoves the HTTP-boundary guarantee. Request DTOs leaking Model references into Controllers defeats the purpose of the FormRequest → DTO Flow — the DTO stops being a stable contract and becomes a thin shell around Eloquent internals.
Split by dependency content (carries Models → Result, doesn't → Input)RejectedClassifies by consequence. Produces mis-placed files (pure-primitive Action returns stay in Input). Kendo's PR #901 used this rule and PR #903 had to relocate 15 additional files by the correct rule.
Split by data shape (scalar → Input, Collection → Result)RejectedArbitrary. Doesn't address boundary discipline — a scalar Action return is still an Action return and semantically belongs where returns live.
Three-namespace structure (Input / Service-transfer / Result)RejectedBIO had this (DataTransferObjects\ + Data\ + Http\Resources\) and recon showed the middle namespace was redundant. Usage-direction collapses "Service-transfer" into Input — a Service-returned DTO consumed as an Action parameter is Input from the Action's perspective, regardless of who produced it.
Split by usage direction at Action boundaryAcceptedOne semantic rule, machine-checkable via reflection on Action signatures. Eliminates the double-loop workaround. Standardizes cross-project — one vocabulary across territories.

Consequences

Positive

  • Arch tests enforce placement mechanically. Three reflection-based Pest tests catch misplacement at CI time — the next soldier can't accidentally drop a Result DTO into Input without the test firing.
  • Workarounds collapse. The double-loop in BIO's GetFamilySetCompletionAction was pure ceremony forced by a layer constraint. With Result DTOs allowed to carry Collections of Models, the Action returns the Collection directly and the ResourceData shapes in one pass.
  • Cross-project consistency. One vocabulary (Input / Result) across every territory that adopts the pattern. Easier for allies who work across multiple repos; easier for the General drafting orders.
  • FormRequest toDto() type-safety hardening. Test #3 forces explicit return-type declarations on every toDto() method. Kendo's arch test caught zero untyped methods on a 74-FormRequest sweep — but the guardrail now exists if a future soldier writes one.
  • Retires redundant machinery. BIO's ResourceDataSourceInterface marker (the generic constraint for ComputedResourceData<TSource>) becomes unnecessary when Result DTOs feed ResourceData directly. One fewer interface to maintain.

Negative

  • Per-territory migration cost. Kendo: 15 file moves in PR #901 + 15 more in PR #903 (scope expansion from arch-test catch). BIO: 19 file moves + double-loop fix + App\Data\ retirement. Ublgenie and entreezuil are estimated similar footprints, plus ally-facing negotiation overhead.
  • Deptrac configuration grows. Two leaf layers instead of one, each with its own allowed_dependencies list per consumer layer. Small cost, but not zero.
  • Vocabulary churn on territories with established naming. BIO had "Intake Forms / Internal Transfer Slips / Shipping Labels" in its doctrine; the namespace rename forced CLAUDE.md rewrites. The analogy survives, the folder names change — but it is disruption.
  • The rule is not the most intuitive first reading. "Service-returned DTOs are Input" surprises developers who classify by producer. The ADR, the arch test failure messages, and the CLAUDE.md projections all need to explain the Action-boundary framing or the rule feels arbitrary.

Risks

  • Soldiers mis-classify edge cases (Enums vs DTOs, Paginator wrappers, nested generics) — Mitigation: the arch test explicitly skips non-DTO types; the filter logic is documented in each test's body.
  • A DTO gets dual-purposed (Action A's return is Action B's input) — Mitigation: arch test #2 fires, forcing the developer to define two distinct classes. This is a feature — shared DTOs across Action boundaries is how doctrine drift accumulates.
  • Third-party or framework types at Action boundaries confuse reflectionMitigation: tests resolve types by App\DataTransferObjects\ FQCN prefix only; external types are transparent. Kendo and BIO both ran clean against this.

Enforcement

WhatMechanismScope
Action return-type DTOs must live in Result/Pest architecture test (reflection on execute() return type)app/Actions/
Action execute() DTO parameters must live in Input/Pest architecture test (reflection on parameter types)app/Actions/
FormRequest toDto() return types must be declared + must live in Input/Pest architecture test (reflection on method return type)app/Http/Requests/
Input DTOs must not depend on ModelsDeptrac layer constraint (InputDTOsModels forbidden)app/DataTransferObjects/Input/
Result DTOs may depend on ModelsDeptrac layer constraint (ResultDTOsModels allowed)app/DataTransferObjects/Result/

Reference architecture test

The canonical Pest implementations live in the kendo territory at backend/tests/Arch/DataTransferObjectsTest.php. Ported implementations in other territories must preserve the three assertion names and the skip logic. An example of the first test:

php
test('action return types that are DTOs live in the Result namespace', function (): void {
    $actions = collect(File::allFiles(app_path('Actions')))
        ->filter(fn (SplFileInfo $f) => str_ends_with($f->getFilename(), 'Action.php'));

    foreach ($actions as $file) {
        $fqcn = // resolve FQCN from file path
        if (! method_exists($fqcn, 'execute')) { continue; }

        $returnType = (new ReflectionMethod($fqcn, 'execute'))->getReturnType();
        if (! $returnType instanceof ReflectionNamedType) { continue; }

        $returnTypeName = $returnType->getName();
        if (! str_starts_with($returnTypeName, 'App\\DataTransferObjects\\')) { continue; }

        expect($returnTypeName)->toStartWith('App\\DataTransferObjects\\Result\\',
            "{$fqcn}::execute() returns a DTO that must live in Result\\, got: {$returnTypeName}",
        );
    }
});

Resolved Questions

Service returns a DTO → Action consumes it. Is it Input or Result?

Resolved 2026-04-21. Input, measured from the Action's perspective. The Action receives the DTO as a parameter; arch test #2 forces it into Input/. The Service's return type is coincidental — what matters is the boundary where the DTO enters the Action layer. This is why App\Data\Brickognize\* and App\Data\Lego\* (Service return types) live under Input/ after BIO's migration: they are UpsertSetAction::execute(LegoSetData) parameters.

Can a Result DTO be reused as an Input to a later Action?

Resolved 2026-04-21. No, by arch test. If Action A returns OrderResultData and Action B declares execute(OrderResultData $order), test #2 (parameters must live in Input) fires. The rule forces two distinct classes. Reuse across Action boundaries is the path to doctrine drift — the split enforces separation by design.

What about FormRequest toDto() with no declared return type?

Resolved 2026-04-21. Failure. Test #3 fires with an explicit message requesting the return type. Kendo's arch test caught zero untyped methods on a 74-FormRequest sweep; BIO ran clean on the same axis. The guardrail is dormant but armed — if an ally or a careless soldier writes one, CI stops them.

Why not keep the App\Data\ namespace for Service-internal transfer shapes?

Resolved 2026-04-21. The early General's-recommendation was to preserve BIO's existing App\Data\ for Service → Action transit DTOs, carving out only Action-return shapes to Result/. The Commander pushed back: under the Action-boundary rule, Service-returned shapes consumed by Actions are Input, so there is no third role to preserve. Retiring App\Data\ entirely and distributing its contents across Input/ (Brickognize/Lego/) and Result/ (FamilyStats/FamilyMissingParts/BrickDna/FamilySetCompletion) produces a cleaner mental model — one namespace, one rule.

What about the order's out-of-scope Collection-returning Actions?

Resolved 2026-04-21. Actions that return raw Collection<Model> (e.g., BIO's GetFamilySetsAction, GetStorageOptionsAction, GetStorageOptionPartsAction) are not forced into Result/ wrappers by the arch tests — test #1 skips non-DTO return types. Whether to normalize them into Result DTOs is a per-territory judgment call. BIO deferred this at order-draft time; the decision is reversible at any future date without touching the ADR.

Territories with an existing third DTO namespace — forced to migrate?

Resolved 2026-04-21. Yes. The ADR-level rule is "one Input namespace, one Result namespace, nothing else classified as a DTO layer." BIO had DataTransferObjects\ + Data\; the migration retired Data\ entirely. If another territory has App\ViewModels\ or App\Queries\ that semantically live at the Action boundary, they must collapse into the Input/Result split. Non-Action-boundary types (e.g., QueryBuilder wrappers, internal Service helpers that never cross into Actions) are unaffected.

Implementation

TerritoryStateNotes
kendoCompletePR #901 (Input/Result split, 15 moves) + PR #903 (usage-direction arch tests, 15 additional moves from scope expansion). Deptrac InputDTOs + ResultDTOs layers. Three arch tests in tests/Arch/DataTransferObjectsTest.php.
brick-inventoryCompletePR #160 (19 moves + double-loop fix). App\Data\ retired entirely. ResourceDataSourceInterface retired. ComputedResourceData::from() relaxed to object parameter. GetFamilySetCompletionAction double-loop eliminated. BIO's sovereign ADR-0010 amended to reflect the namespace retirement.
ublgenieIn ProgressPR #130 open awaiting ally (Krypt0nBull3t) 2026-04-21. Phase 1 scope — 10 Action-boundary DTOs moved to Input/ (6 domain subfolders preserved); zero Result DTOs today. Result/ created with .gitkeep. Nine non-Action-boundary DTOs stay at root — 6 Eloquent Castable value objects (AddressDto, ItemDto, PartyDto, PaymentDto, TaxTotalDto, TaxSubTotalDto), the DTO interface, and Service→Job shapes (AzureInvoiceDto, AnalysisResult). Narrowed root DataTransferObject Deptrac layer preserves their allowances. Phase 2 (relocation to App\ValueObjects\ or similar) deferred as a separate future campaign. UpdateInvoiceDataData composes root Castables — FormRequest layer keeps both InputDTOs and root DataTransferObject Deptrac edges; the bright line InputDTOs → Model still holds.
entreezuilCompletePR #111 (merged 2026-04-21). 10 file moves (8 Input + 2 Result). Deptrac InputDTOs + ResultDTOs layers with InputDTOs → Model forbidden. Three arch tests ported from kendo into tests/Arch/DataTransferObjectsTest.php. No double-loop pattern present — returns are raw Models / Collections or scalar types; Result DTOs confined to the two kiosk-pairing returns.
the-laboratoryPartialThe Crucible Complete (PR #11 merged 2026-04-21, commit 3a6e51c). Path X strict-namespace rename — App\DTOs\App\DataTransferObjects\ — plus Input/Result split: 10 Input + 3 Result (Action returns) + 3 Result-composition moves. DtoArchitectureTest.php grew from 7 to 10 tests (three reflection assertions adapted to lab's getClassesInDirectory helper). Lab uses plural Deptrac layer names (InputDTOs, ResultDTOs) matching the lab's Models/Enums style. DeptracCoverageArchitectureTest grouping exception extended to cover the new app/DataTransferObjects/ directory. Three clean Action Input→Result pairs (BuildForgeCalendarAction, ComputeForgeInsightsAction, BuildForgeLedgerAction) were the validation case for the ADR. Gatekeeper, War Table, Parlour, Smokestacks deferred per the sovereign-experiment clause — per-experiment rollout. War Table is the next candidate (21 DTOs, 5 Result at Action boundary, rich aggregation domain). Parlour (2 DTOs) and Gatekeeper (0 Result at Action boundary) likely deferred permanently.

Propagation Checklist

  • [ ] ADR added to war room CLAUDE.md Active ADRs list
  • [ ] VitePress sidebar updated (presentation/.vitepress/config.ts Cross-Project section)
  • [ ] Presentation index (presentation/decisions/index.md) Cross-Project table updated
  • [ ] MEMORY.md ADR section updated with ADR-0020 entry
  • [ ] Kendo backend/CLAUDE.md projection (already present from PRs #901 + #903; verify the projection wording matches the ADR's usage-direction framing)
  • [ ] BIO backend/CLAUDE.md projection (already present from PR #160; verify)
  • [ ] Enforcement queue entry tracking ublgenie + entreezuil + the-laboratory adoption readiness

Architecture documentation for contributors and collaborators.