Data model
Model City persists to PostgreSQL. The schema exists in two shapes that share the same tables, columns and constraints — what differs is the referential integrity across verticals:
- Microservices — one database per data-owning vertical:
modelcity-core,modelcity-engagement,modelcity-mobility,modelcity-leisure. Cross-vertical references are soft ids (noFOREIGN KEY, because the referenced row lives in another service's database). - Monolith — a single
modelcitydatabase where those same soft ids become real foreign keys against thecore-owned tables (users,zones,neighbourhoods).
Diagrams use Mermaid erDiagram. See Dual topology for the runtime
consequences of the split.
Cross-cutting conventions
Identifiers
- Business and reference tables use a surrogate
BIGSERIALprimary key (id). The exceptions areusers, whose PK is the Auth0sub(VARCHAR(128), external and immutable), andoperation_authorizations, whose PK is an application-generatedUUID. - Internal
namecolumns (zones, neighbourhoods, …) are kebab-case and uniquely constrained;display_nameis the human-readable label.
Soft references vs. real foreign keys
The structural difference between topologies concentrates on the columns that cross a
vertical boundary (typically toward users, zones or neighbourhoods, owned by core):
citizen_sub, owner_sub, agent_sub, user_sub, zone_id, neighbourhood_id, …
- In microservices they are soft references with no
FOREIGN KEY; integrity is enforced at the application layer (the gateway propagatessubviaX-Auth-Sub, andCoreClientresolves name/role). - In the monolith they become real foreign keys, yielding a fully integral relational model.
Internationalization
Localizable content follows the side translation table pattern: the base row keeps the
Spanish (es) value as fallback, and a <entity>_translations table stores the other
locales, keyed (<entity>_id, locale) with a FOREIGN KEY … ON DELETE CASCADE. See
Internationalization.
Audit trails
Each vertical keeps a <vertical>_trails table recording write operations. In
microservices the responsible user/zone/neighbourhood are soft refs; in the monolith they
are foreign keys ON DELETE SET NULL (deleting a user anonymizes their trail rather than
removing it). See Audit trails.
Core Module
core owns identity and the territorial hierarchy, so all its internal relations are real
foreign keys even in microservices. Other verticals reach these capabilities through
CoreClient, never by direct database access.
zones/neighbourhoods— two-level territorial hierarchy (zone → neighbourhood).neighbourhoods.zone_idisON DELETE RESTRICT.users— citizens and staff, keyed by the Auth0sub.roleis one ofMODEL_CITY_PLATFORM_ADMIN,MODEL_CITY_OPERATOR,MODEL_CITY_BACKOFFICE,MODEL_CITY_MOBILITY_AGENT,MODEL_CITY_CITIZEN;statusisACTIVE/DISABLED.dni_hashis an irreversible HMAC of the DNI set on first certificate verification.operation_authorizations— single-use OTP challenges for sensitive operations. The code is never stored in clear (otp_hash), with a lifecycle (PENDING → VERIFIED → BURNT/EXPIRED) and a boundedattempts_remaining.
Engagement Module
Citizen participation: civic questions (with objectives, voted YES/NO) and geolocated security alerts.
civic_questionskeeps denormalizedyes_count/no_countfor fast reads.answersdeduplicates one vote per verified DNI and question viauq_answers_dni (question_id, dni_hash); no PII is stored in clear.security_alertscarryseverity(IMPORTANT/MEDIUM/MILD) and are considered inactive onceexpires_atis in the past.
Leisure Module (modelcity-leisure)
The largest vertical: city-places, city-routes (N:M via city_route_places),
public-spaces with reservable resources and reservations, and ticketed events.
eventsenforce cross-column consistency:paid ⇒ price > 0,¬paid ⇒ price = 0andpaid ⇒ requires_ticket. Soft-deleted viaactive.event_ticketscarry both Stripe identifiers so the web (Checkout Session) and mobile (Payment Intent) purchase flows coexist. Status is one ofPENDING/PAID/PURCHASED/CANCELLED/REFUNDED.event_refundsis 1:1 with a ticket (UNIQUE (ticket_id));automaticmarks refunds triggered by deleting an event.
Mobility Module (modelcity-mobility)
Urban mobility: citizen cars, street reservations (regulated parking paid via Stripe Checkout) and parking sanctions.
street_reservationshave a Stripe payment lifecycle (PENDING → PAID/CANCELLEDvia webhook) and a self-reference (renewed_from_id) for renewals.sanctionslink tocarsonly bylicense_plateat the application level (not a FK), so a sanction can be issued against an unregistered vehicle.
Monolith — consolidated modelcity schema
The monolith puts every vertical's tables in one database and materializes the cross-vertical
soft ids as real foreign keys to core.
The soft-ref → foreign-key promotion, with delete semantics:
| Table | Column | Microservices | Monolith |
|---|---|---|---|
civic_questions | zone_id / neighbourhood_id | soft ref | FK ON DELETE RESTRICT |
answers | citizen_id | soft ref | FK → users ON DELETE CASCADE |
security_alerts | zone_id / neighbourhood_id | soft ref | FK (RESTRICT / SET NULL) |
cars | owner_sub | soft ref | FK → users ON DELETE CASCADE |
street_reservations | user_sub | soft ref | FK → users ON DELETE CASCADE |
sanctions | agent_sub | soft ref | FK → users ON DELETE RESTRICT |
space_reservations | citizen_sub | soft ref | FK → users ON DELETE CASCADE |
event_tickets | citizen_sub | soft ref | FK → users ON DELETE CASCADE |
event_refunds | issued_by_sub | soft ref | FK → users ON DELETE SET NULL |
*_trails | responsible_user_id, zone_id, neighbourhood_id | soft ref | FK ON DELETE SET NULL |
Design consequences: full referential integrity (no orphan votes, tickets, reservations or
sanctions); deleting a citizen cascades their personal data but anonymizes
(SET NULL) the audit trails and staff-issued refunds; a sanction's issuing agent is
protected by ON DELETE RESTRICT. All internal per-vertical relations, CHECKs, indexes,
translation tables and *_trails tables are identical to the microservices schemas.
The schema is applied through the deployable's Flyway migrations
(src/main/resources/db/migration). Cities add additive V2+ migrations; adding a column
never conflicts with a platform upgrade. See the
Extensibility Guide and
Add a database migration.