I’ve witnessed countless brilliant teams make the same foundational mistakes. One of the most common and devastating is failing to recognize that data shapes must be tailored explicitly for each DISTINCT consumer and use case. The clarity with which you structure and communicate data is often the single greatest factor determining the long-term viability of your software.
When your application spans multiple surfaces, like dashboards, mobile apps, internal tooling, and third-party APIs, it’s tempting to create one generic data structure and propagate it everywhere. This impulse is understandable, since it seems simpler at first. But simplicity quickly dissolves when each interface begins to demand unique fields, transformations, and constraints. Before you realize it, you’re burdened by an entangled mess of conditional logic, brittle schema checks, and confusion around which fields matter and where.
To avoid this trap, clearly define and separate your input and output shapes. Input shapes, your DTOs, describe explicitly what your API accepts, and nothing more. Output shapes, ROs, describe exactly what your clients consume. These two shapes should never match exactly. They represent fundamentally different concerns and audiences. A transaction submitted by a frontend user isn't the same shape as the one returned in a mobile app feed, nor is it identical to the shape your internal database demands.
Never succumb to vague naming conventions like Simple, Minimal, Full, Extended, SuperExtended, or Compact. Such terms lose meaning almost instantly as new features and interfaces appear, because they inherently rely on a relative comparison without explicitly stating what the reference point is. For example, if you name something Compact, you must clarify "compact compared to what?" Otherwise, the term becomes ambiguous and confusing. Instead, name shapes explicitly based on their purpose and consumer. When a mobile app needs transaction data for a user feed, call it TransactionMobileFeedRo. If your admin dashboard needs data to review flagged transactions, name it TransactionAdminReviewRo. When a backend endpoint updates user permissions, call it UserAccessUpdateDto. Clear naming ensures everyone, including engineers and even the AI assistants they use, instantly grasps each shape's intent and purpose.
Organize these shapes per domain, not by arbitrary technical categories. Each
domain, such as transactions, users, or notifications, should independently
own its DTOs, ROs, queries, mappers, and event-handling logic. For example,
under the transaction domain, place DTOs and ROs into
domains/transaction/models, clearly named to reflect their use case, such as
transaction-create-schema-dto.ts
or transaction-mobile-feed-schema-ro.ts
(if they're small you can group them in the same file). This makes it way
easier to understand which contracts exist, who consumes them, and why.
The critical mapping between internal entities and external read objects happens explicitly in dedicated mapper modules within each domain. Mappers have one clear purpose: translating internal data models, with precise query structures, into the exact output shapes clients consume. You don't pass raw database models directly into your API responses. Instead, you explicitly transform query results through mappers, clearly named for source and destination, such as toMobileFeedRo.
Query constraints and database joins are not ad-hoc or embedded arbitrarily throughout your codebase. Instead, encapsulate them into clearly defined query helper modules per domain. Methods within these helpers, like joinForAdminReview or whereReimbursed, explicitly declare expected results through strong typing, so your mappers always receive predictable shapes.
Every input from external clients must first be rigorously validated and shaped as a DTO before reaching your business logic. You do not infer shapes implicitly or guess types based on incoming requests. Explicitly naming each DTO after its specific use case (e.g., TransactionReimbursementRequestDto) ensures each input is purposeful, verifiable, and maintainable.
When executing core domain logic, do not directly interact with infrastructure like databases or messaging systems. Instead, inject clearly defined services (repositories, notification services, audit trails). This facilitates straightforward testing, easy swapping of dependencies, and clear visibility into every piece of infrastructure used.
Important domain actions, like freezing a card, reimbursing a transaction, or verifying a user, should emit domain events rather than directly executing every side effect. Events like CardFrozenEvent or TransactionReimbursedEvent clearly communicate significant occurrences within your system. Subscribers can react independently, this enables loose coupling and scalable asynchronous operations without muddying the originating domain logic.
Critically, never blur boundaries between domains. The user domain shouldn't directly format transaction names, and notifications shouldn’t directly access reimbursement details. Every domain maintains ownership over its models, DTOs, ROs, queries, and events. Cross-domain interactions occur only through explicitly defined services or read models. This way you can have clear contracts, and reduce the unintended side effects.