Expose a new field on the API (end-to-end)
Goal: add a field — here videoUrl on an event — that is accepted on create/update
and returned on reads. This is the full clean-architecture path: it touches the schema,
the entity, the DTOs, the store and the producing use cases, all through subclasses.
- Seams:
EventDto/EventRequestDto(extensible DTOs),EventStore(port),EventController(abstract base), the read/write*UseCaseinterfaces. - Schema change: additive
V2+migration. - Applies to: both topologies.
The platform DTOs, use cases and controller are generic over the DTO types
(EventController<T extends EventDto, S extends EventSummaryDto, R extends EventRequestDto>,
GetEventUseCase<T extends EventDto>, …). Binding the controller to your MyEventDto forces
Spring to inject use cases that produce MyEventDto, so a half-override (new DTO but old
use cases) fails fast at startup instead of silently dropping your field.
1. The migration
-- V2__add_video_url_to_events.sql
ALTER TABLE events ADD COLUMN video_url VARCHAR(2048);
2. The entity + a widened read view
Add the column on a subclass entity, and widen the read view so the DTO factory can see
it (the platform EventView doesn't know about videoUrl).
// store/model/MyEventView.java — extends the platform read port
public interface MyEventView extends EventView {
String getVideoUrl();
}
// repository/model/MyEvent.java
@Entity
@Table(name = "events")
@SuperBuilder
@NoArgsConstructor
@Getter
@Setter
public class MyEvent extends EventBase implements MyEventView {
@Column(name = "video_url", length = 2048)
private String videoUrl;
}
// repository/MyEventRepository.java
public interface MyEventRepository extends EventRepository<MyEvent> {}
3. The DTO subclasses
DTOs are plain extensible classes (not records) precisely so a city can subclass them.
// controller/model/MyEventDto.java
@Getter
@Setter
@NoArgsConstructor
public class MyEventDto extends EventDto {
private String videoUrl;
public static MyEventDto from(MyEventView e, String locale) {
MyEventDto dto = new MyEventDto();
// copy the platform fields, then add ours
EventDto base = EventDto.from(e, locale);
BeanUtils.copyProperties(base, dto); // org.springframework.beans.BeanUtils
dto.setVideoUrl(e.getVideoUrl());
return dto;
}
}
// controller/model/MyEventRequestDto.java
@Getter
@Setter
@NoArgsConstructor
public class MyEventRequestDto extends EventRequestDto {
private String videoUrl;
}
4. The store that persists it
Bind the port to your entity and request subtype; it replaces DefaultEventStore
(see Replace a persistence adapter for the full pattern).
@Component
@RequiredArgsConstructor
public class MyEventStore implements EventStore<MyEvent, MyEventRequestDto> {
private final MyEventRepository repository;
@Override
public MyEvent create(MyEventRequestDto request, String stripePriceId) {
MyEvent event = new MyEvent();
event.setActive(true);
applyPlatformFields(event, request); // name, description, price, translations… (mirror DefaultEventStore)
event.setStripePriceId(stripePriceId);
event.setVideoUrl(request.getVideoUrl());
return repository.save(event);
}
// update(...), findActiveById(...), search(...), softDelete(...) mirror DefaultEventStore,
// returning MyEvent instead of Event.
}
5. The producing use cases (bound to the subtype)
Every use case that returns an EventDto must be overridden to build MyEventDto.
Re-declare the platform caching/transaction annotations you want to keep
(see Override a write use case).
@Service
@RequiredArgsConstructor
public class MyCreateEventUseCase implements CreateEventUseCase<MyEventDto, MyEventRequestDto> {
private final EventStore<MyEvent, MyEventRequestDto> eventStore;
private final EventWriteValidator validator;
private final StripeFacade stripeFacade;
private final SystemTrailGenerator trails;
@Override
@Transactional
@CacheEvict(cacheNames = CacheNames.EVENTS, allEntries = true)
public MyEventDto execute(String sub, MyEventRequestDto request, String locale) {
validator.validate(request);
String priceId = request.isPaid()
? stripeFacade.createPrice(LocalizedText.requireDefault("name", request.getName()),
request.getPrice(), request.getCurrency().toUpperCase())
: null;
MyEvent saved = eventStore.create(request, priceId);
trails.eventCreated(sub, saved);
return MyEventDto.from(saved, locale);
}
}
Do the same for the reads you expose (GetEventUseCase<MyEventDto>,
GetEventForEditUseCase<MyEventDto>, UpdateEventUseCase<MyEventDto, MyEventRequestDto>).
GetEventsUseCase returns the lighter EventSummaryDto; override it only if videoUrl must
appear in the list too.
6. The controller, bound to the subtypes
@RestController
public class MyEventController extends EventController<MyEventDto, EventSummaryDto, MyEventRequestDto> {
public MyEventController(
GetEventsUseCase<EventSummaryDto> getEventsUseCase,
GetEventUseCase<MyEventDto> getEventUseCase,
GetEventForEditUseCase<MyEventDto> getEventForEditUseCase,
CreateEventUseCase<MyEventDto, MyEventRequestDto> createEventUseCase,
UpdateEventUseCase<MyEventDto, MyEventRequestDto> updateEventUseCase,
DeleteEventUseCase deleteEventUseCase) {
super(getEventsUseCase, getEventUseCase, getEventForEditUseCase,
createEventUseCase, updateEventUseCase, deleteEventUseCase);
}
}
The abstract EventController already carries @RequestMapping("/events") and the endpoint
methods; you only bind the types. DefaultEventController and DefaultEventStore back off
automatically.
Notes
@RequestBody @Valid R requestbinds your subtype, so the extra field is validated and populated by Jackson without touching the base controller.- Missing any producing use case for
MyEventDtomakes the app fail to start (unsatisfied constructor) — that is the intended guard, not a bug. @JsonInclude(NON_NULL)onEventDtois inherited, sovideoUrlis simply omitted when null.
Verify
Start the app (a green start proves the wiring and the validate schema check). Then:
curl -X POST /events -H 'X-Auth-Sub: <admin>' -H 'Content-Type: application/json' \
-d '{ "...": "...", "videoUrl": "https://youtu.be/abc" }'
curl /events/{id} # → the response includes "videoUrl"