Domain model
20 entities, mapped with EF Core against SQL Server. Configuration lives in a single place — Persistence/AppDbContext.cs — with no attribute-based mapping scattered across the entity classes.
Entity relationships
erDiagram
ApplicationUser ||--o| ProviderProfile : "activates"
ApplicationUser ||--o{ RefreshToken : holds
ApplicationUser ||--o{ PasswordResetCode : requests
ApplicationUser ||--o{ TokenTransaction : ledger
ApplicationUser ||--o{ TokenPurchase : buys
ApplicationUser ||--o{ Notification : receives
ApplicationUser ||--o{ FavoriteListing : saves
ApplicationUser ||--o{ FavoriteProvider : follows
ApplicationUser ||--o{ Referral : "invites (referrer)"
ApplicationUser ||--o| Referral : "invited by (unique)"
ProviderProfile ||--o{ Listing : publishes
ProviderProfile ||--o{ ProviderCategory : "works in"
Category ||--o{ ProviderCategory : groups
Category ||--o{ Category : "parent of"
Category ||--o{ Listing : classifies
Listing ||--o{ ListingImage : gallery
Listing ||--o{ ListingBoost : boosted
Listing ||--o{ BookingRequest : "booked via"
Listing ||--o{ Review : reviewed
Listing ||--o{ DiscountTokenOffer : "negotiated on"
BookingRequest ||--o| ServiceExecution : completes
BookingRequest ||--o| Review : "may produce"
ApplicationUser ||--o{ Conversation : "user1 / user2"
Conversation ||--o{ Message : contains
Conversation ||--o{ DiscountTokenOffer : "offered in"
ApplicationUser ||--o{ Message : sends
Entities by module
Identity and accounts
| Entity | Notes |
|---|---|
ApplicationUser |
extends IdentityUser. Carries TokenBalance, IsProvider, IsActive, ReferralCode (unique, 8 chars) and the denormalized SearchName index column |
RefreshToken |
one row per issued refresh token, revocable, 30-day lifetime |
PasswordResetCode |
six-digit single-use code, stored SHA-256 hashed, with expiry and a failed-attempt counter |
Referral |
who invited whom, and which of the two reward instalments have been paid |
PasswordResetCode exists instead of Identity's built-in GeneratePasswordResetTokenAsync because that token is long and unsuited to being typed into a mobile app, while Identity's TOTP provider (which does produce six digits) has a fixed three-minute window — too short if the email sits in a delivery queue. A dedicated table gives full control over expiry, attempt limits and single use.
Catalogue
| Entity | Notes |
|---|---|
Category |
self-referencing tree via ParentId, unique Slug, 188 rows seeded |
ProviderProfile |
one per provider user, holds AverageRating, TotalReviews, TotalListings, IsVerified |
ProviderCategory |
join table — which categories a provider works in |
Listing |
the advertised service; price mode, status, view count, boost state and four denormalized search columns |
ListingImage |
gallery images, ordered by SortOrder |
Listing carries PriceMode (Fixed, Range, Negotiable) with FixedPrice or the PriceFrom/PriceTo pair, so the client can render "3.500 RSD", "2.000–4.000 RSD" or "by agreement" from one row.
Booking and reviews
| Entity | Notes |
|---|---|
BookingRequest |
RequestedDate/RequestedTime as DateOnly/TimeOnly, plus AcceptedAt which starts the three-day execution clock |
ServiceExecution |
created when a provider marks the service done; unique per booking, which is what makes the token reward unrepeatable |
Review |
1–5 stars, optional comment, optional link to the booking it came from |
Token economy
| Entity | Notes |
|---|---|
TokenTransaction |
append-only ledger. Every movement records Kind, Amount and BalanceAfter |
TokenPurchase |
a purchase of tokens for RSD, with bonus tokens and a status |
ListingBoost |
tokens spent for visibility over a fixed duration |
DiscountTokenOffer |
a client offering tokens to a provider in exchange for a discount |
BalanceAfter is stored on every ledger row deliberately. It makes the wallet history auditable on its own, without replaying the whole ledger to explain any single line to a user.
Messaging and notifications
| Entity | Notes |
|---|---|
Conversation |
exactly one per pair of users, enforced by a unique index on (User1Id, User2Id) with normalized ordering |
Message |
Text is stored AES-256-CBC encrypted; deleted after MessageRetentionDays |
Notification |
typed by NotificationKind, with an optional polymorphic ReferenceType/ReferenceId pointer |
Favourites
FavoriteListing and FavoriteProvider are separate tables rather than one polymorphic table, so each can carry a real foreign key and a real unique constraint.
Enums
| Enum | Values |
|---|---|
ListingStatus |
Active, Paused, Archived |
PriceMode |
Fixed, Range, Negotiable |
BookingStatus |
Pending, Confirmed, Rejected, Completed, Cancelled |
DiscountOfferStatus |
Pending, Accepted, Rejected, Cancelled |
ReferralStatus |
Pending, Registered, Rewarded |
TokenKind |
ServiceReward, Purchase, BoostSpend, DiscountSent, DiscountReceived, Referral, AdminGrant |
TokenPurchaseStatus |
Pending, Completed, Failed |
NotificationKind |
12 values covering messages, bookings, tokens, reviews, boosts, offers and referrals |
Enums are serialized as strings (JsonStringEnumConverter), so the API contract does not silently change meaning when a value is inserted into the middle of an enum.
Constraints that encode business rules
Several rules are enforced by the database rather than only by service code, so they hold even under concurrency or a future code path that forgets to check.
| Constraint | Rule it enforces |
|---|---|
Referral.ReferredUserId unique |
a user can be referred exactly once, ever |
ApplicationUser.ReferralCode unique |
referral codes never collide |
Conversation (User1Id, User2Id) unique |
one conversation per pair, no duplicates from a double-tap |
Review (ListingId, AuthorId) unique |
one review per person per listing |
ServiceExecution.BookingRequestId unique |
a booking can be completed once, so its token reward pays once |
ProviderProfile.UserId unique |
one provider profile per account |
Category.Slug unique |
slugs are safe to use as public identifiers |
FavoriteListing (UserId, ListingId) unique |
toggling twice concurrently cannot create duplicates |
The conversation constraint only works because User1Id/User2Id are normalized to a consistent order before insert — otherwise (A,B) and (B,A) would be two different rows describing one conversation.
Indexes
Beyond the primary and foreign keys:
| Index | Purpose |
|---|---|
Listing (Status, IsBoosted, BoostScore, CreatedAt) |
the default browse ordering — boosted first, then score, then recency — served entirely from the index |
Listing (SearchLocation, Status) |
city filtering becomes an index seek instead of a scan |
AspNetUsers (SearchName) |
admin user search over the folded name column |
Message (ConversationId, SentAt) |
paging a conversation from newest backwards |
TokenTransaction (UserId, CreatedAt) |
the wallet ledger view |
PasswordResetCode (UserId, UsedAt) |
finding the one active code for a user |
RefreshToken (Token) |
refresh lookup on every token rotation |
The composite listing index replaced an earlier (Status, IsBoosted) index. Same prefix, but by carrying BoostScore and CreatedAt it also covers the sort, so the default listing query needs no separate sort operation.
Delete behaviour
OnDelete is set explicitly on every relationship, never left to convention. The pattern:
- Cascade where the child has no meaning without the parent — a listing's images, a conversation's messages, a provider profile when the account goes.
- Restrict where deletion would destroy history — a user with token transactions, bookings or reviews cannot simply vanish, because the counterparty's records reference them.
That is also why account deletion is a deactivation (IsActive = false) rather than a row delete: the other side of every past booking, review and conversation has a legitimate claim on those records.
Migrations
Ten migrations, applied automatically at startup by db.Database.MigrateAsync().
| Migration | Change |
|---|---|
InitialCreate |
full initial schema |
AddReferralSystem |
referral codes and the Referral table |
AddExpandedCategories |
the 188-category seed |
AddBookingAcceptedAt |
AcceptedAt, enabling the three-day rule |
RemoveTokenAwardedFromServiceExecution |
reward tracking moved to the ledger |
AddFavorites |
favourite listings and providers |
NormalizeMediaUrlsToRelative |
rewrote stored absolute image URLs to relative paths |
AddPasswordResetCodes |
the hashed six-digit reset code table |
AddSearchIndexColumns |
the denormalized Search* columns and their indexes |
SplitReferralRewardIntoTwoInstalments |
split the single referral payout in two |
Production migrations are not applied by the running container on Azure; a dedicated CD job generates an idempotent SQL script and applies it before the new image is deployed. See deployment.md.
Related
- Token economy — how the ledger entities are actually used
- Search engine — what the
Search*columns are for - Architecture — the services that own these entities