Normalization
This phase deliberately ignores the design from ERModel (P1) and RelationalDesign (P2) as a starting point. Instead it starts over from a single flat relation containing every attribute of the model, derives the functional dependencies that hold on it, and decomposes it formally, step by step. The final section compares what falls out of that process with the P2 design.
De-normalized database form
Building one relation out of the whole model
The ER model has ten entity/relationship sets carrying attributes (see
ERModel): Users, Cryptos, Markets, Orders,
Transactions, MarketTrades, MarketCandles, Watchlists, and the two attributed
relationships Holds and Contains. Eight more relationships (QuotedOn, PlacedOn,
Places, Records, Settles, Fills, Aggregates, Owns) carry no attributes of their
own — in Chen notation they need none, because the diagram expresses the link itself as a
relationship, not a column. A single flat relation has no such device: the only way to keep
one entity's rows pointed at another's is a plain attribute holding the referenced key,
which is exactly what P2's ER-to-relational transformation already introduces for each of
those eight relationships (markets.crypto_id, orders.market_id, orders.user_id,
transactions.user_id, transactions.related_order, market_trades.market_id,
market_candles.market_id, watchlists.user_id). Those linking attributes are included
below for that reason — not because they were copied from P2's design, but because a "single
table with everything in it" cannot represent the model at all without them.
Every attribute name is prefixed by a two-or-three-letter code for the entity/relationship it
came from, because several names repeat across the model (id, created_at, quantity,
type, name, price, side all appear more than once) and the de-normalized relation may
not contain duplicate names.
| Prefix | Origin (P1 entity / relationship) | Attributes |
|---|---|---|
U_ | Users | U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT
|
C_ | Cryptos | C_ID, C_SYMBOL, C_NAME, C_CREATED_AT
|
M_ | Markets (+ QuotedOn) | M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT
|
H_ | Holds (+ surrogate key) | H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT
|
O_ | Orders (+ PlacedOn, Places) | O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT
|
T_ | Transactions (+ Records, Settles) | T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION
|
MT_ | MarketTrades (+ Fills) | MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE
|
MC_ | MarketCandles (+ Aggregates) | MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME
|
W_ | Watchlists (+ Owns) | W_ID, W_USER_ID, W_NAME, W_CREATED_AT
|
WI_ | Contains (+ surrogate key) | WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT
|
H_ID and WI_ID exist for the same reason they exist in P2: Holds and Contains are M:N
relationships with their own attributes, and giving each its own surrogate key (rather than
relying solely on the {user,crypto} / {watchlist,crypto} pair) is the same design choice
already justified in RelationalDesign (section "Descriptive representation of the relational schema").
This gives one relation, R_EDUBERZA, of 68 attributes:
R_EDUBERZA( U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT, C_ID, C_SYMBOL, C_NAME, C_CREATED_AT, M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT, H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT, O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT, T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION, MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE, MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME, W_ID, W_USER_ID, W_NAME, W_CREATED_AT, WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT )
Every attribute is single-valued and atomic (a balance, a timestamp, a symbol, an amount —
nothing here is a list or a nested record), so R_EDUBERZA satisfies 1NF as soon as it is
written down. Whether it satisfies anything beyond that is exactly what the rest of this page
checks.
Functional dependencies
Canonical cover
Read directly off the model: each entity's/relationship's own key determines its own attributes, nothing more. This is already minimal — no functional dependency below has an extraneous attribute on its left side, and no dependent attribute is repeated on the right side of more than one dependency, which is what "canonical cover" requires.
| # | Functional dependency | Source |
|---|---|---|
| FD1 | U_ID → U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT | Users |
| FD2 | U_USERNAME → U_ID | Users (UNIQUE(username))
|
| FD3 | U_EMAIL → U_ID | Users (UNIQUE(email))
|
| FD4 | C_ID → C_SYMBOL, C_NAME, C_CREATED_AT | Cryptos |
| FD5 | C_SYMBOL → C_ID | Cryptos (UNIQUE(symbol))
|
| FD6 | M_ID → M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT | Markets |
| FD7 | M_CRYPTO_ID, M_QUOTE_CURRENCY → M_ID | Markets (UNIQUE(crypto_id, quote_currency))
|
| FD8 | H_ID → H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT | Holds |
| FD9 | H_USER_ID, H_CRYPTO_ID → H_ID | Holds (UNIQUE(user_id, crypto_id))
|
| FD10 | O_ID → O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT | Orders |
| FD11 | T_ID → T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION | Transactions |
| FD12 | MT_ID → MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE | MarketTrades |
| FD13 | MC_ID → MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME | MarketCandles |
| FD14 | MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME → MC_ID | MarketCandles (UNIQUE(market_id, timeframe, candle_time))
|
| FD15 | W_ID → W_USER_ID, W_NAME, W_CREATED_AT | Watchlists |
| FD16 | WI_ID → WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT | Contains |
| FD17 | WI_WATCHLIST_ID, WI_CRYPTO_ID → WI_ID | Contains (UNIQUE(watchlist_id, crypto_id))
|
Minimality, checked by example (Markets): could FD7 drop an attribute from its left side?
M_CRYPTO_ID alone does not determine M_ID — many markets can reference the same crypto in
different quote currencies (that is the entire point of the market entity), so two rows can
share M_CRYPTO_ID and disagree on M_ID. M_QUOTE_CURRENCY alone fails the same way in the
other direction. Neither attribute is extraneous, so the left side of FD7 cannot shrink. The
same check applies to FD9, FD14 and FD17, whose composite left sides come directly from the
UNIQUE constraints already justified per-relation in
RelationalDesign; none of those constraints
holds on a proper subset of its columns either.
No redundant dependency: each of FD1–FD17 has a right side that is not implied by any
other dependency in the set — for instance, nothing outside FD1 mentions U_AVAILABLE_BALANCE,
so FD1 cannot be derived from the rest and cannot be dropped. This set is the canonical cover.
Dependencies carried by foreign keys
Six attributes above are foreign keys: M_CRYPTO_ID, H_USER_ID, H_CRYPTO_ID,
O_USER_ID, O_MARKET_ID, T_USER_ID, T_RELATED_ORDER, MT_MARKET_ID, MC_MARKET_ID,
W_USER_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID — each one draws its values from the same
domain as some other attribute's key. Because of that, every dependency that holds on the
referenced key also holds, by substitution, on the referencing attribute:
| Foreign key | References | Therefore also determines |
|---|---|---|
M_CRYPTO_ID | C_ID | C_SYMBOL, C_NAME, C_CREATED_AT
|
H_USER_ID | U_ID | all of U_*
|
H_CRYPTO_ID | C_ID | all of C_*
|
O_USER_ID | U_ID | all of U_*
|
O_MARKET_ID | M_ID | all of M_*, and transitively all of C_*
|
T_USER_ID | U_ID | all of U_*
|
T_RELATED_ORDER | O_ID | all of O_*, and transitively U_*, M_*, C_* (when not null)
|
MT_MARKET_ID | M_ID | all of M_*, transitively C_*
|
MC_MARKET_ID | M_ID | all of M_*, transitively C_*
|
W_USER_ID | U_ID | all of U_*
|
WI_WATCHLIST_ID | W_ID | all of W_*, transitively U_*
|
WI_CRYPTO_ID | C_ID | all of C_*
|
None of these is added to the canonical cover — each is derivable from FD1–FD17 by transitivity plus the foreign-key identity, which is exactly why a canonical cover excludes them. They matter anyway: they are precisely the transitive dependencies the 3NF check below has to rule out.
Candidate keys and primary key
Orders, Transactions, MarketTrades, MarketCandles, Holds, Watchlists and
Contains are, with respect to each other, independent record types: nothing about an
order's id says anything about which market-candle row, or which unrelated transaction, or
which watchlist item is in the same tuple of R_EDUBERZA — a user can exist with zero of any
of them, and having one order says nothing about how many holdings, trades or candles exist
alongside it. (The one FK that crosses between two of these — T_RELATED_ORDER — is
nullable, so it cannot be relied on to always connect a transaction row back to an order.)
That means no proper subset of attributes can functionally determine all 68 attributes of
R_EDUBERZA: the only way to pin down a H_* value, an O_* value, a T_* value, an
MT_* value, an MC_* value, a W_* value and a WI_* value at once is to state one
identifying attribute from each cluster explicitly.
Chosen primary key (closure shown below):
{ U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID }
Closure check, applying FD1–FD17 in turn to this set:
| Step | Attributes added | Dependency used |
|---|---|---|
| start | U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID | — |
| 1 | U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT | FD1 (U_ID → …)
|
| 2 | C_SYMBOL, C_NAME, C_CREATED_AT | FD4 |
| 3 | M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT | FD6 |
| 4 | H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT | FD8 |
| 5 | O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT | FD10 |
| 6 | T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION | FD11 |
| 7 | MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE | FD12 |
| 8 | MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME | FD13 |
| 9 | W_USER_ID, W_NAME, W_CREATED_AT | FD15 |
| 10 | WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT | FD16 |
The closure now contains all 68 attributes, so the set is a superkey; removing any one of its
ten attributes drops an entire cluster that nothing else in the set can reach (e.g. drop
T_ID and no remaining attribute determines any T_* value), so it is minimal — a candidate
key.
It is not the only one. Any attribute that is itself a determinant of a whole cluster can
stand in for that cluster's id — U_USERNAME or U_EMAIL for U_ID (FD2/FD3), C_SYMBOL
for C_ID (FD5), {M_CRYPTO_ID, M_QUOTE_CURRENCY} for M_ID (FD7), {H_USER_ID, H_CRYPTO_ID} for H_ID (FD9), {MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME} for MC_ID
(FD14), {WI_WATCHLIST_ID, WI_CRYPTO_ID} for WI_ID (FD17) — giving 3 × 2 × 2 × 2 × 1 × 1 ×
1 × 2 × 1 × 2 = 96 candidate keys in total. The all-surrogate-id combination above is chosen
as primary key for the same reason id was chosen over username/email/symbol/etc.
per entity in ERModel: it is opaque, and none of its parts
are things a user would ever legitimately change.
Normal form of R_EDUBERZA before decomposition: 1NF only, and barely that — see 2NF
below. It cannot be in 2NF, 3NF or BCNF, since each of those requires 2NF as a precondition.
1NF decomposition
No decomposition happens at this step. 1NF requires atomic, single-valued attributes and no
repeating groups; R_EDUBERZA was built that way from the start (every column above is a
single scalar), so the relation already satisfies 1NF as written in
De-normalized database form. The real work starts at 2NF.
2NF decomposition
Relation analyzed: R_EDUBERZA, all 68 attributes, primary key
{U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID, W_ID, WI_ID} (10 attributes), FD1–FD17
in force.
Current normal form: 1NF only (previous section).
Violations: 2NF forbids a non-prime attribute from depending on part of a candidate
key. Every single functional dependency in the canonical cover (FD1–FD17) has a left side
that is a proper subset of the ten-attribute primary key — U_ID alone, C_ID alone, …,
down to the two-attribute {WI_WATCHLIST_ID, WI_CRYPTO_ID}. There is no non-prime attribute
in R_EDUBERZA that depends on the whole ten-attribute key and nothing smaller. In other
words, every non-prime attribute violates 2NF at once — the violation is not a handful of
stray columns to peel off, it is the entire relation, because gluing ten independent record
types together under one artificial composite key was never going to satisfy 2NF to begin
with.
Decomposition. This uses 3NF/BCNF synthesis (Bernstein's algorithm) rather than the binary decomposition algorithm: since the canonical cover is already in hand (as the phase instructions recommend building first), synthesis creates one relation per left-hand side in the cover directly, instead of hunting for one offending dependency at a time and splitting in two repeatedly. Grouping FD1–FD17 by determinant produces ten relations:
| New relation | Attributes | Key(s) | Source FDs |
|---|---|---|---|
R_USERS | U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH, U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT | U_ID, U_USERNAME, U_EMAIL | FD1, FD2, FD3 |
R_CRYPTO | C_ID, C_SYMBOL, C_NAME, C_CREATED_AT | C_ID, C_SYMBOL | FD4, FD5 |
R_MARKETS | M_ID, M_CRYPTO_ID, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT | M_ID, {M_CRYPTO_ID, M_QUOTE_CURRENCY} | FD6, FD7 |
R_HOLDINGS | H_ID, H_USER_ID, H_CRYPTO_ID, H_QUANTITY, H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT | H_ID, {H_USER_ID, H_CRYPTO_ID} | FD8, FD9 |
R_ORDERS | O_ID, O_USER_ID, O_MARKET_ID, O_SIDE, O_TYPE, O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT | O_ID | FD10 |
R_TRANSACTIONS | T_ID, T_USER_ID, T_TYPE, T_AMOUNT, T_CURRENCY, T_RELATED_ORDER, T_CREATED_AT, T_DESCRIPTION | T_ID | FD11 |
R_MARKET_TRADES | MT_ID, MT_MARKET_ID, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY, MT_SIDE, MT_SOURCE | MT_ID | FD12 |
R_MARKET_CANDLES | MC_ID, MC_MARKET_ID, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW, MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME | MC_ID, {MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME} | FD13, FD14 |
R_WATCHLISTS | W_ID, W_USER_ID, W_NAME, W_CREATED_AT | W_ID | FD15 |
R_WATCHLIST_ITEMS | WI_ID, WI_WATCHLIST_ID, WI_CRYPTO_ID, WI_ADDED_AT | WI_ID, {WI_WATCHLIST_ID, WI_CRYPTO_ID} | FD16, FD17 |
Every one of these ten relations now has all of its non-prime attributes depending on its whole key (in every case there is only one non-composite or one designated key doing the determining, so 2NF holds trivially in each).
Dependency preservation. FD1–FD17 is the canonical cover of R_EDUBERZA. Each FD's
determinant and every one of its dependent attributes land inside exactly one of the ten new
relations (see the "Source FDs" column above — no FD is split across two relations). The
union of the FDs that hold on R_USERS, …, R_WATCHLIST_ITEMS is therefore exactly FD1–FD17
again: nothing was lost.
Lossless join — chase test.
Note: the chase algorithm is not part of the course material. I was curious about a stricter way to test lossless join than the usual "the common attributes are a key of one side" argument, so I applied it here.
The chase decides whether a decomposition R = R1 ∪ … ∪ Rn is lossless under a set of
functional dependencies. Build a tableau with one column per attribute of R and one row per
relation Ri. In row i, put a distinguished symbol a in every column of Ri and a unique
symbol b_i in every other column. Then repeat, until nothing changes: for each FD X → Y,
whenever two rows agree on all of X, make them agree on Y. If they disagree, an a wins,
otherwise one b replaces the other. The decomposition is lossless exactly when some row ends up with a in every column.
All attributes of one cluster (U_*, C_*, M_*, …) always appear together, and FD1–FD17 never mix clusters. So each cluster is one column group below: a means every column of the group holds a distinguished symbol, and b means none of them does. The foreign-key attributes (H_USER_ID, O_MARKET_ID, …) belong to their own cluster (H_*, O_*, …), not to the cluster they reference.
Step 1 — the ten relations from the table above.
U* C* M* H* O* T* MT* MC* W* WI* R_USERS a b b b b b b b b b R_CRYPTO b a b b b b b b b b R_MARKETS b b a b b b b b b b R_HOLDINGS b b b a b b b b b b R_ORDERS b b b b a b b b b b R_TRANSACTIONS b b b b b a b b b b R_MARKET_TR. b b b b b b a b b b R_MARKET_CA. b b b b b b b a b b R_WATCHLISTS b b b b b b b b a b R_WATCHLIST_I. b b b b b b b b b a
Every FD has its left side inside one cluster, for example U_ID → U_* or H_USER_ID, H_CRYPTO_ID → H_ID. For such an FD to fire, two rows would have to agree on that left side. But only one row has as in that cluster, and the bs of different rows are all different, so no two rows ever agree on any left side. *The chase changes nothing, and no row becomes all a.' Under FD1–FD17 alone, the ten relations are not guaranteed to join back to R_EDUBERZA. This is not an accident of this model. It is exactly why Bernstein's synthesis algorithm has a final step: *if no synthesised relation contains a candidate key of R, add one that does. None of the ten contains the ten-attribute key.
Step 2 — add the key relation `R_KEY(U_ID, C_ID, M_ID, H_ID, O_ID, T_ID, MT_ID, MC_ID,
W_ID, WI_ID). Its row has a only in the ten ID columns, written a· for "a` in the ID,
b in the rest of the group":
U* C* M* H* O* T* MT* MC* W* WI* R_KEY a· a· a· a· a· a· a· a· a· a· (the ten rows of step 1 unchanged)
Now FD1 U_ID → U_* fires: row R_KEY and row R_USERS both have a in U_ID, so they must agree on the rest of U_*, and R_USERS has a there. R_KEY becomes a in the whole
U* group. The same happens with FD4 (C*), FD6 (M*), FD8 (H*), FD10 (O*), FD11 (T*),
FD12 (MT*), FD13 (MC*), FD15 (W*) and FD16 (WI*):
U* C* M* H* O* T* MT* MC* W* WI* R_KEY a a a a a a a a a a <- all distinguished
Row R_KEY is all a, so the decomposition into the ten relations plus R_KEY is lossless.
Why R_KEY is not kept in the final schema. An instance of R_KEY would only record
which ID of one cluster appears together with which ID of every other cluster. As shown under
Candidate keys and primary key, the ten clusters are independent record types, and
R_EDUBERZA pairs every row of one with every row of the others. So R_KEY would be just the
cross product of the ten ID sets and would carry no information. The same independence means
the join dependency ⋈[R_USERS, …, R_WATCHLIST_ITEMS] holds on R_EDUBERZA by construction.
Under that dependency the ten relations alone already reconstruct it: their natural join, with
no common attributes, is exactly that cross product. The chase makes this reasoning explicit.
FDs by themselves cannot prove the join lossless; you need either the key relation or the
independence of the clusters. That was hidden in the earlier "foreign key equals primary key"
argument, which described the equi-joins the application runs, not the natural join the
lossless-join property is about.
3NF decomposition
Relations analyzed: each of the ten relations produced above, individually.
For each relation, 3NF asks whether any non-prime attribute is transitively dependent on a key — i.e. determined by another non-prime attribute rather than directly by the key. This is exactly where the foreign-key-carried dependencies from Dependencies carried by foreign keys have to be checked, because that table is precisely the list of "dependency that would cause a problem at the next higher normal form" the phase template asks for.
Worked example — R_MARKETS. Its key M_ID determines M_CRYPTO_ID, and
M_CRYPTO_ID → C_SYMBOL, C_NAME, C_CREATED_AT also holds (M_CRYPTO_ID draws its values from
C_ID's domain). If C_SYMBOL, C_NAME and C_CREATED_AT were still columns of
R_MARKETS, this would be exactly the transitive dependency M_ID → M_CRYPTO_ID → C_SYMBOL
that violates 3NF. They are not: the 2NF step above already put them in R_CRYPTO, keyed
directly by C_ID (FD4), because FD4 — not the derived M_CRYPTO_ID → C_SYMBOL — is what the
canonical cover actually contains. R_MARKETS itself has no attribute that determines another
non-prime attribute of R_MARKETS; the transitive dependency is real, but it points out of
the relation, not within it.
The same reasoning applies to every other foreign key in the list: H_USER_ID/H_CRYPTO_ID,
O_USER_ID/O_MARKET_ID, T_USER_ID/T_RELATED_ORDER, MT_MARKET_ID, MC_MARKET_ID,
W_USER_ID, WI_WATCHLIST_ID/WI_CRYPTO_ID are all foreign keys sitting alongside a
non-key attribute set that depends only on their own relation's key, never on the foreign key
itself. None of R_USERS, R_CRYPTO, R_HOLDINGS, R_ORDERS, R_TRANSACTIONS,
R_MARKET_TRADES, R_MARKET_CANDLES, R_WATCHLISTS, R_WATCHLIST_ITEMS has a non-prime
attribute that another non-prime attribute of the same relation determines.
Conclusion: synthesising directly from the canonical cover in the 2NF step already avoided every transitive dependency — there is nothing left to decompose for 3NF. All ten relations from the previous section satisfy 3NF unchanged.
BCNF if possible
Relations analyzed: the same ten relations, checked against the stricter BCNF rule: every determinant of every functional dependency that holds on the relation must be a candidate key of that relation (3NF allows an exception when the dependent side is prime; BCNF does not).
| Relation | Functional dependencies in force | Determinant | Is it a candidate key? |
|---|---|---|---|
R_USERS | FD1, FD2, FD3 | U_ID, U_USERNAME, U_EMAIL | Yes — all three are candidate keys |
R_CRYPTO | FD4, FD5 | C_ID, C_SYMBOL | Yes — both candidate keys |
R_MARKETS | FD6, FD7 | M_ID, {M_CRYPTO_ID, M_QUOTE_CURRENCY} | Yes — both candidate keys |
R_HOLDINGS | FD8, FD9 | H_ID, {H_USER_ID, H_CRYPTO_ID} | Yes — both candidate keys |
R_ORDERS | FD10 | O_ID | Yes — the only candidate key |
R_TRANSACTIONS | FD11 | T_ID | Yes — the only candidate key |
R_MARKET_TRADES | FD12 | MT_ID | Yes — the only candidate key |
R_MARKET_CANDLES | FD13, FD14 | MC_ID, {MC_MARKET_ID, MC_TIMEFRAME, MC_CANDLE_TIME} | Yes — both candidate keys |
R_WATCHLISTS | FD15 | W_ID | Yes — the only candidate key |
R_WATCHLIST_ITEMS | FD16, FD17 | WI_ID, {WI_WATCHLIST_ID, WI_CRYPTO_ID} | Yes — both candidate keys |
Every determinant in every relation is one of that relation's own candidate keys. All ten relations are already in BCNF — the highest of the four normal forms this phase asks for, reached in the same step that fixed 2NF. This is not a coincidence: it happens because the canonical cover already grouped each relation's own key directly against its own attributes with no attribute appearing on the right side of two different relations' dependencies, which is exactly what synthesis from a canonical cover guarantees when, as here, none of the per-cluster functional dependencies overlap.
No further decomposition is possible or necessary; splitting any of the ten relations further would only separate attributes that already depend on the whole key of a BCNF relation, which cannot fix anything and only costs a join.
Final result and discussion
Normalized relational model
R_USERS (U_ID, U_USERNAME, U_EMAIL, U_FULL_NAME, U_PASSWORD_HASH,
U_AVAILABLE_BALANCE, U_INVESTED_BALANCE, U_CREATED_AT, U_UPDATED_AT)
R_CRYPTO (C_ID, C_SYMBOL, C_NAME, C_CREATED_AT)
R_MARKETS (M_ID, M_CRYPTO_ID → R_CRYPTO, M_QUOTE_CURRENCY, M_IS_ACTIVE, M_CREATED_AT)
R_HOLDINGS (H_ID, H_USER_ID → R_USERS, H_CRYPTO_ID → R_CRYPTO, H_QUANTITY,
H_RESERVED_QUANTITY, H_AVG_PRICE, H_CREATED_AT, H_UPDATED_AT)
R_ORDERS (O_ID, O_USER_ID → R_USERS, O_MARKET_ID → R_MARKETS, O_SIDE, O_TYPE,
O_STATUS, O_QUANTITY, O_PRICE, O_PLACED_AT, O_EXECUTED_AT)
R_TRANSACTIONS (T_ID, T_USER_ID → R_USERS, T_TYPE, T_AMOUNT, T_CURRENCY,
T_RELATED_ORDER → R_ORDERS, T_CREATED_AT, T_DESCRIPTION)
R_MARKET_TRADES (MT_ID, MT_MARKET_ID → R_MARKETS, MT_EXECUTED_AT, MT_PRICE, MT_QUANTITY,
MT_SIDE, MT_SOURCE)
R_MARKET_CANDLES (MC_ID, MC_MARKET_ID → R_MARKETS, MC_TIMEFRAME, MC_OPEN, MC_HIGH, MC_LOW,
MC_CLOSE, MC_VOLUME, MC_CANDLE_TIME)
R_WATCHLISTS (W_ID, W_USER_ID → R_USERS, W_NAME, W_CREATED_AT)
R_WATCHLIST_ITEMS(WI_ID, WI_WATCHLIST_ID → R_WATCHLISTS, WI_CRYPTO_ID → R_CRYPTO, WI_ADDED_AT)
Ten relations, every one in BCNF, connected by the eleven foreign keys spelled out above.
Discussion
This is the P2 design. Strip the U_/C_/M_/… prefixes back to plain column names and
R_USERS, R_CRYPTO, R_MARKETS, R_HOLDINGS, R_ORDERS, R_TRANSACTIONS, R_MARKET_TRADES, R_MARKET_CANDLES, R_WATCHLISTS, R_WATCHLIST_ITEMS are, attribute for attribute and key for
key, users, crypto, markets, holdings, orders, transactions, market_trades, market_candles, watchlists, watchlist_items from
RelationalDesign. Every foreign key matches,
every candidate key matches (including the less obvious composite ones — {user_id, crypto_id} on holdings, {crypto_id, quote_currency} on markets, {market_id, timeframe, candle_time} on market_candles), and the normal form matches (P2 already claimed 3NF; this
phase shows the stronger result that the design is actually in BCNF).
That is not a coincidence of two people happening to agree — it is what should happen when a design is derived correctly twice by two different methods from the same underlying model: P2 got here by applying the standard ER-to-relational transformation rules (each entity becomes a table on its own key, each attributed M:N relationship becomes a table on the combined key, each attributeless 1:N relationship becomes a foreign key on the "many" side). This phase got here by ignoring that transformation entirely, writing down only the attributes and the functional dependencies they obey, and mechanically applying 2NF/3NF/BCNF synthesis. Landing on the same ten relations either means the P2 transformation rules are sound for this particular model (which they are, for exactly the reason RelationalDesign (Normalisation section) already argued: single-column UUID primary keys everywhere rule out partial dependencies by construction, and no non-key attribute references another non-key attribute anywhere in the model, which rules out transitive dependencies too), or it is a coincidence spanning ten independently-checked relations and dozens of functional dependencies — the first explanation is the only credible one.
The one substantive difference is holdings.avg_price, which P2 documents as a
derived attribute — the running weighted-average buy price, recomputable from the buy rows
in transactions — kept as a stored column anyway for read performance
(RelationalDesign (Normalisation section) calls this out
explicitly as an accepted denormalisation). Nothing in this phase's functional-dependency
analysis can see that H_AVG_PRICE is derivable from T_* rows rather than stored
independently — FD8 (H_ID → H_AVG_PRICE) is a perfectly ordinary functional dependency
either way, because derivability from a different relation's rows is a property of the data
and the application logic that maintains it (see
UseCase0004's ON CONFLICT … DO UPDATE), not something
that shows up as a violation of any single-relation normal form. Formal normalization and "no
column is a cached computation of other columns" are related but different concerns; this
phase only checked the first one.
Which design is used going forward: P2's, unchanged. Since the two designs coincide
exactly, "restructuring the database objects" means confirming there is nothing to change
rather than writing new DDL. server/db/schema_creation.sql
already matches R_USERS…R_WATCHLIST_ITEMS column-for-column (including
holdings.reserved_quantity, added between P2 and this phase — see
RelationalDesignAIUsage (section "Session 3 — 2026-09-16")
— which is H_RESERVED_QUANTITY above, correctly grouped under R_HOLDINGS's key alongside
H_QUANTITY and not treated as needing a relation of its own). P4's prototype
(server/trade.go, server/portfolio.go) keeps working against the same schema without
change. RelationalDesign has been updated with a
short note pointing here as the formal validation of its normal-form claim.
The table definitions in server/db/schema_creation.sql:
CREATE TABLE project.users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username varchar(50) NOT NULL UNIQUE,
email varchar(255) NOT NULL UNIQUE,
full_name varchar(200),
password_hash varchar(255) NOT NULL,
available_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (available_balance >= 0),
invested_balance numeric(18,4) NOT NULL DEFAULT 0 CHECK (invested_balance >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz
);
-- ============================================================================
-- CRYPTO
-- Catalog of crypto assets available on the platform.
-- ============================================================================
CREATE TABLE project.crypto (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
symbol varchar(20) NOT NULL UNIQUE,
name varchar(255) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ============================================================================
-- MARKETS
-- A market is a (crypto, quote_currency) pair, e.g. BTC/USD.
-- ============================================================================
CREATE TABLE project.markets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
crypto_id uuid NOT NULL REFERENCES project.crypto(id),
quote_currency char(3) NOT NULL DEFAULT 'USD',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_markets UNIQUE (crypto_id, quote_currency)
);
-- ============================================================================
-- HOLDINGS
-- Per-user crypto position with running weighted average entry price.
-- ============================================================================
CREATE TABLE project.holdings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
crypto_id uuid NOT NULL REFERENCES project.crypto(id),
quantity numeric(20,4) NOT NULL CHECK (quantity >= 0),
-- Committed to the user's own open sell orders, not yet removed from the
-- position. quantity - reserved_quantity is what is actually free to
-- sell — the crypto-side equivalent of users.available_balance.
reserved_quantity numeric(20,4) NOT NULL DEFAULT 0
CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity),
-- Weighted-average entry price. NOT NULL so that the P/L arithmetic in
-- v_portfolio can never silently produce NULL for an existing position.
avg_price numeric(18,6) NOT NULL DEFAULT 0 CHECK (avg_price >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz,
CONSTRAINT uq_holdings_user_crypto UNIQUE (user_id, crypto_id)
);
-- ============================================================================
-- ORDERS
-- Orders placed by users on a market.
-- ============================================================================
CREATE TABLE project.orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
market_id uuid NOT NULL REFERENCES project.markets(id),
side varchar(4) NOT NULL CHECK (side IN ('buy', 'sell')),
type varchar(20) NOT NULL CHECK (type IN ('market', 'limit')),
status varchar(20) NOT NULL CHECK (status IN ('open', 'executed', 'cancelled')),
quantity numeric(20,4) NOT NULL CHECK (quantity > 0),
price numeric(18,6),
placed_at timestamptz NOT NULL DEFAULT now(),
executed_at timestamptz
);
CREATE INDEX idx_orders_user ON project.orders(user_id);
CREATE INDEX idx_orders_market ON project.orders(market_id);
CREATE INDEX idx_orders_status ON project.orders(status);
-- ============================================================================
-- TRANSACTIONS
-- Financial ledger: deposits, buys, sells, fees.
-- ============================================================================
CREATE TABLE project.transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
type varchar(50) NOT NULL CHECK (type IN ('deposit', 'buy', 'sell', 'fee')),
amount numeric(18,4) NOT NULL,
currency char(3) NOT NULL DEFAULT 'USD',
related_order uuid REFERENCES project.orders(id),
created_at timestamptz NOT NULL DEFAULT now(),
description text
);
CREATE INDEX idx_transactions_user ON project.transactions(user_id, created_at DESC);
-- ============================================================================
-- MARKET TRADES
-- Raw executed trades on a market. Source of truth for current price.
-- ============================================================================
CREATE TABLE project.market_trades (
id bigserial PRIMARY KEY,
market_id uuid NOT NULL REFERENCES project.markets(id),
executed_at timestamptz NOT NULL,
price numeric(18,6) NOT NULL CHECK (price > 0),
quantity numeric(20,6) NOT NULL CHECK (quantity > 0),
side varchar(4) CHECK (side IN ('buy', 'sell')),
source varchar(50) NOT NULL DEFAULT 'simulation'
);
CREATE INDEX idx_market_trades_market_time ON project.market_trades(market_id, executed_at DESC);
-- ============================================================================
-- MARKET CANDLES
-- OHLCV aggregates over standard timeframes.
-- ============================================================================
CREATE TABLE project.market_candles (
id bigserial PRIMARY KEY,
market_id uuid NOT NULL REFERENCES project.markets(id),
timeframe varchar(5) NOT NULL CHECK (timeframe IN ('1m', '5m', '1h', '1d')),
open numeric(18,6) NOT NULL,
high numeric(18,6) NOT NULL,
low numeric(18,6) NOT NULL,
close numeric(18,6) NOT NULL,
volume numeric(20,6) NOT NULL,
candle_time timestamptz NOT NULL,
CONSTRAINT uq_candle UNIQUE (market_id, timeframe, candle_time)
);
CREATE INDEX idx_market_candles_market_tf_time ON project.market_candles(market_id, timeframe, candle_time DESC);
-- ============================================================================
-- WATCHLISTS
-- ============================================================================
CREATE TABLE project.watchlists (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES project.users(id) ON DELETE CASCADE,
name varchar(100) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE project.watchlist_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
watchlist_id uuid NOT NULL REFERENCES project.watchlists(id) ON DELETE CASCADE,
crypto_id uuid NOT NULL REFERENCES project.crypto(id),
added_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_watchlist_crypto UNIQUE (watchlist_id, crypto_id)
);
