DTO Projection and Read Model
Learn Java Data Access Pattern In Action - Part 029
DTO projection dan read model dalam Java data access: view model, reporting query, list screen optimization, avoiding entity leak, direct projection, constructor mapping, database view, denormalization, projection lag, rebuild, dan testing.
Part 029 — DTO Projection and Read Model
Banyak masalah performa data access bukan karena database lambat.
Seringnya karena aplikasi mengambil bentuk data yang salah.
UI butuh 12 kolom, tetapi aplikasi load aggregate besar.
Report butuh flat row, tetapi aplikasi hydrate entity graph.
Dashboard butuh status dan nama officer, tetapi repository memicu N+1.
DTO Projection dan Read Model adalah cara membaca data sesuai bentuk kebutuhan, bukan sesuai bentuk domain object atau table mentah.
Part ini membahas projection dan read model secara production-grade.
1. Core Thesis
Read path harus mengembalikan shape data yang dibutuhkan consumer, bukan memaksa semua read melewati aggregate/entity.
Projection adalah hasil query yang dipilih untuk kebutuhan baca tertentu.
Read model adalah model baca yang biasanya lebih terstruktur/denormalized untuk query tertentu.
Mental model:
Command side optimizes correctness.
Read side optimizes shape, latency, and query stability.
DTO projection:
public record CaseDashboardRow(
UUID caseId,
String caseNumber,
String status,
String priority,
String assignedOfficerName,
Instant updatedAt
) {}
Bukan aggregate:
CaseFile
Bukan JPA entity:
CaseFileEntity
2. Projection vs Entity vs Aggregate
| Type | Purpose |
|---|---|
| Entity persistence | maps table/object persistence state |
| Aggregate/domain | enforces business behavior/invariant |
| DTO projection | represents read/API/view/report shape |
| Read model | storage/model optimized for read query |
| API response | transport contract exposed to client |
Do not collapse them unless system is simple and trade-off acceptable.
Bad:
Database entity -> JSON response -> frontend
Problems:
- sensitive field leak;
- lazy loading;
- schema becomes API;
- over-fetching;
- circular references;
- serialization surprises;
- migration harder;
- domain behavior mixed with view concerns.
3. Why Projection Matters
Dashboard example:
UI needs:
caseId
caseNumber
status
priority
assignedOfficerName
lastActionAt
Aggregate load may include:
case_file
assignments
reviewers
actions
documents
sanctions
audit
policy snapshot
This is wasteful.
Projection query:
select
c.id,
c.case_number,
c.status,
c.priority,
o.display_name as assigned_officer_name,
c.last_action_at
from case_file c
left join officer o on o.id = c.assigned_officer_id
where c.tenant_id = ?
order by c.last_action_at desc, c.id desc
limit ?
Read exactly what is needed.
4. DTO Projection Pattern
public record CaseDashboardRow(
CaseFileId caseId,
String caseNumber,
CaseStatus status,
Priority priority,
Optional<String> assignedOfficerName,
Instant lastActionAt
) {}
DAO:
public Slice<CaseDashboardRow> search(
Connection connection,
CaseDashboardQuery query
) {
...
}
Mapper:
public CaseDashboardRow mapRow(ResultSet rs) throws SQLException {
return new CaseDashboardRow(
new CaseFileId(Rs.requiredUuid(rs, "case_id")),
Rs.requiredString(rs, "case_number"),
CaseStatus.fromDbCode(Rs.requiredString(rs, "status")),
Priority.fromDbCode(Rs.requiredString(rs, "priority")),
Optional.ofNullable(rs.getString("assigned_officer_name")),
Rs.requiredInstant(rs, "last_action_at")
);
}
Projection has no mutation behavior.
5. Projection Is Use-Case Specific
Good:
CaseDashboardRow
CaseDetailHeader
CaseTimelineRow
CaseExportRow
OfficerWorkloadRow
RegulatoryReportRow
Bad:
CaseDto
that tries to serve:
- dashboard;
- detail;
- export;
- mobile API;
- admin API;
- internal report.
Generic DTO becomes as bad as generic entity.
Make projections specific to read use case.
6. List Screen Optimization
List screen should be intentionally shallow.
Rules:
- select only columns displayed;
- avoid child collection fetch;
- use bounded page/keyset;
- join only needed small reference data;
- avoid
select *; - avoid per-row service calls;
- precompute expensive display fields if needed;
- use read model if joins become unstable.
Example list row:
public record CaseListItem(
CaseFileId id,
String caseNumber,
CaseStatus status,
String officerName,
Instant updatedAt
) {}
Not:
CaseDetailView
7. Detail View Projection
Detail view may need multiple sections:
public record CaseDetailView(
CaseDetailHeader header,
List<CaseActionView> recentActions,
List<CaseDocumentView> documents,
List<CaseAssignmentView> assignments
) {}
Options:
Single large join
Can duplicate parent rows and create complex mapping.
Multiple bounded queries
Often clearer:
@Transactional(readOnly = true)
public CaseDetailView getDetail(CaseFileId id) {
CaseDetailHeader header = query.header(id);
List<CaseActionView> actions = query.recentActions(id, 20);
List<CaseDocumentView> documents = query.documents(id);
List<CaseAssignmentView> assignments = query.assignments(id);
return new CaseDetailView(header, actions, documents, assignments);
}
If consistency matters, use read-only transaction.
8. Avoid Cartesian Explosion
Single query:
select c.*, a.*, d.*
from case_file c
left join case_action a on a.case_id = c.id
left join case_document d on d.case_id = c.id
where c.id = ?
If case has:
20 actions
10 documents
Result:
200 rows
This is cartesian multiplication.
Better:
- separate section queries;
- aggregate child values carefully;
- JSON aggregation if appropriate and reviewed;
- read model;
- bounded child lists.
9. Projection Mapping Styles
Manual JDBC mapper
ResultSet -> DTO
Best control.
JPA constructor expression
select new com.example.CaseDashboardRow(...)
Convenient but still entity manager context.
Interface projection
Useful in Spring Data for simple projections.
jOOQ record mapping
Type-safe SQL DSL.
MyBatis result map
Explicit XML/annotation mapping.
Database view
Encapsulate SQL in DB.
Read model table
Precomputed projection.
Choose based on complexity/performance.
10. Manual Projection Mapper
public final class CaseDashboardRowMapper {
public CaseDashboardRow map(ResultSet rs) throws SQLException {
return new CaseDashboardRow(
new CaseFileId(Rs.requiredUuid(rs, "case_id")),
Rs.requiredString(rs, "case_number"),
CaseStatus.fromDbCode(Rs.requiredString(rs, "status")),
Priority.fromDbCode(Rs.requiredString(rs, "priority")),
Optional.ofNullable(rs.getString("assigned_officer_name")),
Rs.requiredInstant(rs, "updated_at")
);
}
}
Pros:
- explicit;
- fast;
- no reflection;
- mapping errors visible;
- column aliases clear.
Cons:
- boilerplate.
For critical queries, boilerplate is often worth it.
11. Constructor Projection With JPA
List<CaseDashboardRow> rows = entityManager.createQuery("""
select new com.example.CaseDashboardRow(
c.id,
c.caseNumber,
c.status,
c.priority,
o.displayName,
c.updatedAt
)
from CaseFileEntity c
left join c.assignedOfficer o
where c.tenantId = :tenantId
order by c.updatedAt desc, c.id desc
""", CaseDashboardRow.class)
.setParameter("tenantId", tenantId)
.setMaxResults(limit)
.getResultList();
Pros:
- avoids entity hydration;
- integrates with JPA.
Caveats:
- query still JPQL, not always obvious SQL;
- constructor signature coupling;
- enum mapping behavior;
- pagination with joins must be reviewed;
- no automatic magic for complex read models.
12. Interface Projection Caveat
Some frameworks create projection interfaces.
interface CaseDashboardProjection {
UUID getId();
String getCaseNumber();
String getStatus();
}
Useful for simple cases.
Caveats:
- hidden proxy behavior;
- nested projections can trigger extra queries;
- mapping errors at runtime;
- less explicit than record constructor;
- still must avoid entity graph leakage.
Use for simple read, not complex reporting path without review.
13. jOOQ Projection
return dsl.select(
CASE_FILE.ID,
CASE_FILE.CASE_NUMBER,
CASE_FILE.STATUS,
CASE_FILE.PRIORITY,
OFFICER.DISPLAY_NAME,
CASE_FILE.UPDATED_AT
)
.from(CASE_FILE)
.leftJoin(OFFICER).on(OFFICER.ID.eq(CASE_FILE.ASSIGNED_OFFICER_ID))
.where(CASE_FILE.TENANT_ID.eq(tenantId.value()))
.orderBy(CASE_FILE.UPDATED_AT.desc(), CASE_FILE.ID.desc())
.limit(limit)
.fetch(record -> new CaseDashboardRow(
new CaseFileId(record.get(CASE_FILE.ID)),
record.get(CASE_FILE.CASE_NUMBER),
CaseStatus.fromDbCode(record.get(CASE_FILE.STATUS)),
Priority.fromDbCode(record.get(CASE_FILE.PRIORITY)),
Optional.ofNullable(record.get(OFFICER.DISPLAY_NAME)),
record.get(CASE_FILE.UPDATED_AT).toInstant()
));
Pros:
- type-safe columns;
- SQL-first;
- good for projections;
- generated schema.
14. MyBatis Projection
Mapper interface:
List<CaseDashboardRow> search(CaseDashboardQuery query);
XML:
<select id="search" resultMap="CaseDashboardRowMap">
select
c.id as case_id,
c.case_number,
c.status,
c.priority,
o.display_name as assigned_officer_name,
c.updated_at
from case_file c
left join officer o on o.id = c.assigned_officer_id
where c.tenant_id = #{tenantId}
order by c.updated_at desc, c.id desc
limit #{limit}
</select>
Result map:
<resultMap id="CaseDashboardRowMap" type="com.example.CaseDashboardRow">
<constructor>
<arg column="case_id" javaType="java.util.UUID"/>
<arg column="case_number" javaType="java.lang.String"/>
...
</constructor>
</resultMap>
MyBatis works well for explicit SQL projection if mapping discipline is strong.
15. Database View
View:
create view case_dashboard_view as
select
c.id as case_id,
c.tenant_id,
c.case_number,
c.status,
c.priority,
o.display_name as assigned_officer_name,
c.updated_at
from case_file c
left join officer o on o.id = c.assigned_officer_id;
Query:
select *
from case_dashboard_view
where tenant_id = ?
order by updated_at desc, case_id desc
limit ?
Pros:
- centralizes complex SQL;
- usable by multiple consumers;
- DB-level abstraction.
Cons:
- versioning/migration complexity;
- view performance depends on underlying query;
- can hide expensive joins;
- app still needs projection mapping;
- permissions/security must be considered.
16. Materialized View
Materialized view stores result physically.
Pros:
- faster reads;
- precomputed expensive joins/aggregates.
Cons:
- refresh strategy;
- stale data;
- locking/refresh cost;
- not always incremental;
- operational complexity.
Use when read performance needs justify it.
Application read model table is often more controllable than generic materialized view for event-driven systems.
17. Read Model Table
Read model table:
create table case_dashboard_read_model (
case_id uuid primary key,
tenant_id uuid not null,
case_number text not null,
status text not null,
priority text not null,
assigned_officer_id uuid,
assigned_officer_name text,
last_action_at timestamptz not null,
source_version bigint not null,
projected_at timestamptz not null
);
Query becomes simple and indexable:
select
case_id,
case_number,
status,
priority,
assigned_officer_name,
last_action_at
from case_dashboard_read_model
where tenant_id = ?
and status = ?
order by last_action_at desc, case_id desc
limit ?
18. Read Model Update Strategies
Synchronous
Update in same transaction as write.
caseRepository.save(caseFile);
dashboardReadModel.updateFrom(caseFile);
Good for immediate consistency.
Asynchronous event projection
outbox -> projector -> read model
Good for scalability and isolation.
Scheduled rebuild
nightly/hourly rebuild
Good for reports where freshness less critical.
On-demand cache
Compute and cache when needed.
Good for expensive but infrequent reads.
19. Read Model Source Version
Always store source version when projecting mutable source.
source_version bigint not null
Update only if event is newer:
update case_dashboard_read_model
set
status = ?,
priority = ?,
assigned_officer_name = ?,
source_version = ?,
projected_at = ?
where case_id = ?
and source_version < ?;
This prevents old event overwriting newer state.
20. Upsert Projection
Database-specific concept:
insert into case_dashboard_read_model (
case_id,
tenant_id,
case_number,
status,
priority,
assigned_officer_name,
last_action_at,
source_version,
projected_at
)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
on conflict (case_id) do update
set
case_number = excluded.case_number,
status = excluded.status,
priority = excluded.priority,
assigned_officer_name = excluded.assigned_officer_name,
last_action_at = excluded.last_action_at,
source_version = excluded.source_version,
projected_at = excluded.projected_at
where case_dashboard_read_model.source_version < excluded.source_version;
Duplicate event with same/older version no-ops.
21. Projection Event Handler
@Transactional
public void onCaseUpdated(EventEnvelope<CaseUpdatedPayload> event) {
if (!inbox.tryStart(event.eventId(), event.payloadHash())) {
return;
}
CaseUpdatedPayload payload = event.payload();
dashboardReadModel.upsert(new CaseDashboardReadModelRow(
payload.caseId(),
payload.tenantId(),
payload.caseNumber(),
payload.status(),
payload.priority(),
payload.assignedOfficerId(),
payload.assignedOfficerName(),
payload.lastActionAt(),
payload.aggregateVersion(),
clock.instant()
));
inbox.markProcessed(event.eventId(), clock.instant());
}
Inbox and projection update are same transaction.
22. Snapshot Event vs Delta Event
Delta event:
{
"eventType": "CaseStatusChanged",
"caseId": "...",
"newStatus": "APPROVED"
}
Consumer must know existing projection state.
Snapshot event:
{
"eventType": "CaseDashboardSnapshotChanged",
"caseId": "...",
"status": "APPROVED",
"priority": "HIGH",
"assignedOfficerName": "A. Wijaya",
"aggregateVersion": 8
}
Snapshot event is easier for read model upsert.
Trade-off:
- larger payload;
- producer couples to read model needs;
- may expose more data;
- easier rebuild/update.
Often use domain event internally, integration projection event externally.
23. Denormalization
Read model duplicates data intentionally.
Example:
case_dashboard_read_model.assigned_officer_name
instead of joining officer table every time.
Benefits:
- faster dashboard;
- fewer joins;
- stable query plan;
- read scalability.
Costs:
- stale data risk;
- update propagation needed;
- rebuild strategy;
- storage duplication;
- ownership clarity.
Denormalization is not anti-normalization if controlled.
24. Denormalized Name Change
If officer name changes, dashboard rows must update.
Options:
- publish
OfficerNameChangedand projector updates relevant rows; - dashboard joins officer for name dynamically;
- store officer ID only and resolve at API layer;
- rebuild read model periodically;
- accept stale historical display name if domain says name-at-time.
Choose by semantics.
Question:
Should dashboard show current officer name or name at assignment time?
This is domain/read contract, not just SQL.
25. Derived Fields
Read model can store derived fields:
sla_breached
days_open
risk_bucket
last_action_summary
document_count
active_assignment_count
But derived fields need:
- update trigger/event;
- recomputation rule;
- source version;
- rebuild;
- test cases;
- timezone/time source clarity.
For time-dependent derived fields like days_open, maybe compute at read time unless expensive.
26. Reporting Projection
Reports often need stable snapshot.
RegulatoryReportRow:
public record RegulatoryReportRow(
String caseNumber,
String status,
String decision,
Instant decidedAt,
String officerName,
String legalBasis
) {}
Report query should be separate from dashboard query.
For large/regulatory reports:
- create report run;
- capture filters/cutoff;
- snapshot rows;
- export snapshot;
- store file hash/count.
Do not use live dashboard projection blindly if report has evidence requirements.
27. Avoid Entity Leak to JSON
Bad:
@GetMapping("/cases/{id}")
public CaseFileEntity get(@PathVariable UUID id) {
return entityManager.find(CaseFileEntity.class, id);
}
Risks:
- lazy loading;
- sensitive fields;
- bidirectional recursion;
- schema/API coupling;
- accidental update if entity managed;
- unpredictable serialization.
Better:
public CaseDetailResponse get(CaseFileId id) {
CaseDetailView view = caseDetailQuery.get(id);
return responseMapper.toResponse(view);
}
28. Projection and API Response
Projection is internal read shape. API response is external contract.
They may be same record in small service, but usually separate.
CaseDetailView -> CaseDetailResponse
Why separate?
- API versioning;
- field naming;
- localization;
- formatting;
- permissions redaction;
- backward compatibility;
- internal read model evolution.
Do not expose internal read model table shape as public API unintentionally.
29. Redaction and Field-Level Security
Projection query should avoid selecting unauthorized fields.
If field access depends on user role:
- create separate projection/query;
- apply SQL-level conditional selection carefully;
- map response with redaction;
- do not fetch sensitive data if not needed.
Example:
CaseDetailForOfficer
CaseDetailForSupervisor
CaseDetailForAuditor
Sometimes role-specific DTO is clearer than one huge nullable/redacted DTO.
30. Projection and N+1 Prevention
N+1 often appears when mapping DTO from entity graph.
Bad:
List<CaseFileEntity> entities = repo.findPage();
return entities.stream()
.map(e -> new Row(e.getCaseNumber(), e.getOfficer().getDisplayName()))
.toList();
If getOfficer() lazy-loads per row, N+1.
Projection query join fixes:
select c.case_number, o.display_name
from case_file c
left join officer o on ...
DTO projection is one of the strongest N+1 prevention tools.
31. Projection and Aggregation
List screen may need counts.
document_count
open_action_count
Options:
- subquery;
- join + group by;
- lateral join/vendor-specific;
- read model precomputed count;
- separate count query per page;
- batch load counts for visible IDs.
Avoid per-row count query.
Example batch count:
select case_id, count(*) as document_count
from case_document
where case_id = any(?)
group by case_id;
Then merge into DTO in memory.
32. Projection and JSON Aggregation
Some databases support JSON aggregation.
select
c.id,
c.case_number,
json_agg(json_build_object('id', d.id, 'name', d.file_name)) as documents
from case_file c
left join case_document d on d.case_id = c.id
where c.id = ?
group by c.id
Pros:
- one query;
- nested DTO shape.
Cons:
- vendor-specific;
- mapping JSON;
- large payload risk;
- query complexity;
- harder index/plan review.
Use when it simplifies bounded detail view, not for unbounded list.
33. Projection and Database View Versioning
If using database view, migration must handle:
- view create/replace;
- dependent permissions;
- column rename/add/drop;
- app rolling deployment compatibility;
- old app expecting old view shape;
- tests.
View is schema artifact. Treat it like table migration.
34. Projection and Caching
Projection can be cached more safely than aggregate because it is read-only shape.
Cache key must include:
- tenant;
- user scope;
- filters;
- sort;
- page/cursor;
- API version if response cached.
Risks:
- stale authorization;
- invalidation complexity;
- high-cardinality keys;
- memory pressure.
Prefer read model before adding cache to hide bad query.
35. Projection and Search Index
For text-heavy search, use search index/read model.
Pattern:
source DB -> outbox event -> index projector -> search index
Search result returns IDs/projection fields.
Then detail read can fetch from source/read model.
Search index is eventually consistent; expose that contract.
36. Projection and Pagination Stability
Projection query must have deterministic order.
Bad:
order by updated_at desc
If same timestamp, order unstable.
Good:
order by updated_at desc, case_id desc
Cursor includes both:
record CaseCursor(Instant updatedAt, CaseFileId caseId) {}
Read model should have index matching order.
37. Projection and Large Result Export
Do not reuse pageable dashboard projection for export if export needs:
- all rows;
- snapshot consistency;
- audit evidence;
- file hash;
- restart;
- streaming;
- special columns;
- stable cutoff.
Create export projection:
CaseExportRow
CaseExportQuery
Export path is batch/large-result, not interactive list.
38. Projection and Memory
Even projection can OOM if unbounded.
Never:
List<CaseExportRow> rows = exportQuery.findAll();
Use chunk/cursor:
List<CaseExportRow> rows = exportQuery.readAfter(cursor, 1000);
DTO projection reduces row width, not result cardinality.
39. Read Model Rebuild Strategy
Read model should be rebuildable.
Approaches:
Full rebuild offline
create new table
populate
swap
Incremental rebuild
scan source by ID
upsert projection
Event replay
replay events from beginning/checkpoint
Hybrid
snapshot source then apply events after checkpoint
Need decide:
- source of truth;
- rebuild duration;
- read availability during rebuild;
- consistency with live updates;
- version conflict rules.
40. Rebuild With Version
During rebuild, live projector may update same row.
Use source version.
If rebuild event has older source version, it should not overwrite newer live projection.
where read_model.source_version < excluded.source_version
This makes rebuild/live projection race safer.
41. Projection Lag
Measure:
now - event.occurred_at
now - outbox.created_at
now - projection.projected_at
source_version - projected_version
Metrics:
read_model.lag.seconds{model="case_dashboard"}
projection.apply.count{model}
projection.error.count{model}
projection.gap.count{model}
Dashboard stale data is operational signal.
42. Read Model Ownership
Who owns read model?
Options:
- same service as source;
- reporting service;
- API composition layer;
- analytics platform.
Define:
- schema owner;
- update owner;
- source event owner;
- freshness SLO;
- rebuild process;
- access permissions.
Read model without ownership becomes data swamp.
43. Projection Testing
Test direct projection query:
- maps fields correctly;
- filters correctly;
- sorts deterministically;
- pagination works;
- tenant isolation;
- authorization redaction;
- joins do not duplicate rows;
- null optional fields;
- enum mapping;
- count/aggregate correctness.
Test read model projector:
- inserts new row;
- updates newer version;
- ignores duplicate/old version;
- handles missing optional data;
- handles deletion/tombstone;
- inbox dedup;
- gap detection if required.
44. Projection Query Test Example
@Test
void dashboardProjectionReturnsOneRowPerCase() {
CaseId caseId = fixture.caseWithDocuments(3);
Slice<CaseDashboardRow> result =
dashboardQuery.search(filterFor(caseId), PageRequest.first(20));
assertThat(result.items())
.extracting(CaseDashboardRow::caseId)
.containsExactly(caseId);
}
This catches join multiplication.
45. Projector Idempotency Test
@Test
void duplicateEventDoesNotDuplicateProjection() {
CaseUpdatedEvent event = fixture.caseUpdatedEvent(version(8));
projector.handle(event);
projector.handle(event);
CaseDashboardReadModelRow row = readModel.find(event.caseId()).orElseThrow();
assertThat(row.sourceVersion()).isEqualTo(8);
assertThat(inbox.count(event.eventId())).isEqualTo(1);
}
46. Old Event Test
@Test
void oldEventDoesNotOverwriteNewProjection() {
projector.handle(caseUpdatedEvent(version(9), status(APPROVED)));
projector.handle(caseUpdatedEvent(version(8), status(UNDER_REVIEW)));
CaseDashboardReadModelRow row = readModel.find(caseId).orElseThrow();
assertThat(row.status()).isEqualTo(APPROVED);
assertThat(row.sourceVersion()).isEqualTo(9);
}
47. Projection Deletion/Tombstone
If source aggregate deleted/archived:
Options:
- delete projection row;
- mark projection status
DELETED; - keep historical projection for audit;
- move to archive read model.
Event:
{
"eventType": "CaseArchived",
"aggregateVersion": 12
}
Projector:
update case_dashboard_read_model
set status = 'ARCHIVED',
source_version = ?
where case_id = ?
and source_version < ?;
Do not ignore delete/archive events or stale data remains visible.
48. Projection for External API Composition
API may need data from multiple services.
Avoid synchronous fan-out per row if list endpoint.
Options:
- API composition for small detail endpoint;
- read model built from events;
- backend-for-frontend projection;
- cached reference data;
- batch calls.
For list/dashboard, event-driven read model is often better than per-row remote calls.
49. Projection Failure Modes
Common failures:
- projection lags silently;
- duplicate event creates duplicate row;
- old event overwrites newer row;
- missing event leaves stale row;
- join duplicates parent;
- read model has no rebuild;
- projection stores sensitive data without access control;
- query exposes cross-tenant rows;
- field semantics drift from source;
- dashboard uses read model for command validation.
50. Projection Review Checklist
- Projection is use-case specific.
- It selects only needed fields.
- It avoids entity leak.
- It avoids aggregate loading for list/report.
- Query has deterministic order.
- Pagination is bounded.
- Tenant/authorization applied before data leaves DB.
- Join multiplication handled.
- N+1 avoided.
- Read model source version stored if async.
- Projector idempotent.
- Old/duplicate events handled.
- Rebuild strategy exists.
- Projection lag measured.
- API response separated if needed.
- Sensitive fields redacted/not selected.
51. Anti-Pattern: Entity as API Response
Leaky, fragile, unsafe.
Use DTO/response model.
52. Anti-Pattern: One DTO for Everything
Creates huge nullable object and unstable contract.
Use use-case-specific projection.
53. Anti-Pattern: Projection Without Version
Async read model can be overwritten by older events.
Store source version.
54. Anti-Pattern: Dashboard Query With Full Aggregate
Over-fetching and N+1.
Use projection query/read model.
55. Anti-Pattern: Read Model With No Rebuild
If projection bug happens, no recovery.
Design rebuild from start.
56. Anti-Pattern: Denormalized Data With No Ownership
If officer name appears in ten read models, who updates it?
Define ownership/update events.
57. Mini Lab
Design read model for:
Case dashboard for supervisors
Fields:
- case number;
- status;
- priority;
- assigned officer name;
- active reviewer count;
- last action summary;
- SLA breach flag;
- source version;
- projection timestamp.
Questions:
- Which fields come from case source?
- Which fields come from officer source?
- Which fields are derived?
- Which events update the read model?
- How is duplicate event handled?
- How is old event handled?
- What index supports dashboard query?
- How is officer name change propagated?
- What is rebuild strategy?
- What freshness SLO is acceptable?
58. Summary
DTO projection and read model are essential for production read performance and clean architecture.
You must master:
- projection vs aggregate/entity;
- list screen optimization;
- detail view section queries;
- avoiding cartesian explosion;
- manual/JPA/jOOQ/MyBatis projection mapping;
- database view/materialized view;
- read model table;
- synchronous vs asynchronous projection;
- source version;
- idempotent projector;
- denormalization ownership;
- reporting/export projection;
- avoiding entity leak;
- N+1 prevention;
- pagination stability;
- read model rebuild;
- projection lag monitoring;
- security/redaction;
- projection testing.
Part berikutnya membahas Data Mapper vs Active Record: dua cara memodelkan persistence dan mengapa Java enterprise cenderung memilih Data Mapper untuk sistem kompleks.
59. References
- Jakarta Persistence Specification: https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2
- Hibernate ORM User Guide: https://docs.hibernate.org/stable/orm/userguide/html_single/
- Spring Data JPA Projections: https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html
- jOOQ Record Mapping: https://www.jooq.org/doc/latest/manual/sql-execution/fetching/
- MyBatis Result Maps: https://mybatis.org/mybatis-3/sqlmap-xml.html
- PostgreSQL
CREATE VIEW: https://www.postgresql.org/docs/current/sql-createview.html - PostgreSQL Materialized Views: https://www.postgresql.org/docs/current/rules-materializedviews.html
You just completed lesson 29 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.