Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
- 12Relational Databases and Data Modeling for System Design
- 13Database Indexes and Query Performance: How to Make Reads Fast
- 14Transactions, Isolation Levels and Concurrency Control: Preventing Race Conditions
- 15Database Replication: Primary-Replica, Read Replicas, Lag and Failover
- 16Database Sharding and Partitioning: How It Works and When You Need It
- 17SQL vs NoSQL: Data Models and How to Choose the Right Database
6 · Distributed Systems
- 18Why Distributed Systems Are Hard: Partial Failure, Timeouts and Clocks
- 19CAP Theorem and Consistency Models Explained Without the Myths
- 20Idempotency, Retries and Message Delivery Guarantees
- 21Distributed Transactions: Two-Phase Commit, the Outbox Pattern and Sagas
- 22Distributed Locks, Leases, Leader Election and Consensus
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
- 33Design a Notification System: Email, SMS and Push at Scale
- 34Design a Chat System Like a Messaging App
- 35Design a News Feed: Fan-Out, Ranking and the Celebrity Problem
- 36Design Search Autocomplete (Typeahead)
- 37Design a Distributed Job Scheduler
- 38Design a Ticket Booking System Without Overselling
- 39Design a Payment System: Idempotency, Ledgers and Reconciliation
11 · Large-Scale Case Studies
- 40Design a Video Streaming Platform: Upload, Transcoding and Adaptive Streaming
- 41Design a Ride-Hailing System: Location, Matching and Real-Time Updates
- 42Design a Food Delivery Platform: Orders, Restaurants and Delivery Partners
- 43Design an E-Commerce System: Catalog, Cart, Checkout, Inventory and Flash Sales
- 44Design a Web Search Engine: Crawling, Inverted Indexes and Ranking
12 · Interview Readiness
Lesson 12 · Databases
Relational Databases and Data Modeling for System Design
How to model data for a system design: tables, primary and foreign keys, constraints, one-to-many and many-to-many relationships, normalisation and deliberate denormalisation, ACID transactions, choosing id formats, and designing the model around access patterns. Also covers connection pooling, the N+1 problem and safe schema changes.
For
Beginners and SDE-1 candidates who need to produce a sensible data model in a design, and developers who know some SQL and want the system-design view.
Level
Before you start
Read API Design (article 04), because the API's operations become the data model's access patterns, and Estimating Storage, Bandwidth, Cache Size and Server Count (article 07) for row sizes and connection counts. Basic SQL helps; the article on SQL for beginners on this site covers it.
On this page
Key takeaways
- Start a data model by listing access patterns: which queries run, how often, and with what filters and order; then shape tables, keys and indexes to serve them.
- Use primary keys, foreign keys and constraints to let the database enforce correctness that application code would otherwise get wrong under concurrency.
- Normalise to store each fact once; denormalise deliberately, for measured read paths, and know how the copies are kept in sync.
- ACID transactions make multi-step changes all-or-nothing and durable, and are a major reason to choose a relational database.
- Database connections are a limited resource: size pools from throughput and query time, and use a pooler when many servers connect.
Every system in this course stores data, and most interview designs spend at least five minutes on the data model. Yet many candidates treat it as an afterthought: a list of tables with an id column each, drawn after the architecture. That is backwards. The data model decides which queries are fast, which are impossible, where contention appears, and later, how the data can be split across machines. A good model makes the architecture simple; a poor one makes every later decision harder.
This article teaches relational modelling for system design. It is not a SQL course. It focuses on the decisions: keys, relationships, constraints, how much to normalise, and above all how to design around access patterns.
What you will learn
#- What a database gives you that files and caches do not.
- Tables, rows, primary keys, foreign keys and constraints.
- Modelling one-to-one, one-to-many and many-to-many relationships.
- Normalisation and when to denormalise on purpose.
- ACID transactions, in plain words.
- How to choose id formats: auto-increment, UUIDs, time-ordered ids.
- Designing the model from access patterns, with two worked schemas.
- Connection pooling, the N+1 query problem, and safe schema changes.
What a database gives you
#You could store data in files or in a cache. A database adds properties that are very hard to build yourself:
- Durability: acknowledged writes survive crashes.
- Querying: find, filter, join and sort data without loading it all.
- Concurrency control: many clients read and write at once without corrupting data.
- Constraints: rules (this email is unique, this order references a real customer) enforced on every write, by every client.
- Transactions: several changes succeed or fail together.
Relational databases (PostgreSQL, MySQL, SQL Server, Oracle and cloud versions of them) provide all five and have done so for decades. They are the right default for most structured application data. Article 17 covers when another kind of database fits better.
The relational model in five ideas
#- Tables hold one kind of thing: users, photos, orders.
- Rows are individual things; columns are their attributes, each with a type.
- A primary key uniquely identifies each row.
- A foreign key is a column that refers to the primary key of another table, linking rows.
- Constraints are rules the database enforces.
CREATE TABLE users ( user_id BIGINT PRIMARY KEY, username VARCHAR(30) NOT NULL UNIQUE, email VARCHAR(254) NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE photos ( photo_id BIGINT PRIMARY KEY, owner_id BIGINT NOT NULL REFERENCES users(user_id), caption VARCHAR(2200), object_key VARCHAR(300) NOT NULL, status VARCHAR(12) NOT NULL CHECK (status IN ('uploading', 'processing', 'visible', 'rejected')), created_at TIMESTAMPTZ NOT NULL DEFAULT now());Constraints are correctness, not decoration
#Constraints matter more in system design than they first appear. Consider "usernames must be unique". You could check in application code: "is this name taken? no? insert it". With two servers handling two signups for the same name at the same moment, both checks pass and both inserts succeed. A UNIQUE constraint makes the database reject the second insert, however many servers you run. PostgreSQL's documentation describes the constraint types (check, not-null, unique, primary key, foreign key and exclusion) and how each restricts the data allowed in a table (PostgreSQL: Constraints).
You will see this again and again: a unique constraint is often the simplest correct answer to "how do we prevent duplicates?", including duplicate payments and double-booked seats (articles 20 and 38).
Relationships
#Relationship | Example | How to model it |
|---|---|---|
One-to-one | A user and their settings | Same table, or a second table whose primary key is also a foreign key to users |
One-to-many | A user has many photos | Foreign key on the "many" side: |
Many-to-many | Users follow many users; photos have many tags | A join table with two foreign keys: |
CREATE TABLE follows ( follower_id BIGINT NOT NULL REFERENCES users(user_id), followee_id BIGINT NOT NULL REFERENCES users(user_id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (follower_id, followee_id), CHECK (follower_id <> followee_id));The composite primary key means a user can follow another only once (no duplicate rows), and the check stops users following themselves. Two rules, enforced for free.
Joins combine related tables at query time:
-- Photos from people that user 42 follows, newest firstSELECT p.photo_id, p.owner_id, p.caption, p.created_atFROM follows fJOIN photos p ON p.owner_id = f.followee_idWHERE f.follower_id = 42 AND p.status = 'visible'ORDER BY p.created_at DESCLIMIT 20;This query is correct, and for a user who follows a few hundred people it is fine. For a user who follows 5,000 accounts, at 3,500 feed requests per second, it is far too expensive to run on every request. That is not a SQL problem; it is an access pattern problem, and article 35 solves it. Keep this example in mind.
Normalisation and denormalisation
#Normalisation means storing each fact in exactly one place. A user's name lives in users, not copied into every photo row. Benefits: no contradictions (you cannot have two different names for one user), smaller storage, simple updates.
Denormalisation means deliberately copying data to make reads faster. Examples:
- A
like_countcolumn onphotos, instead of runningCOUNT(*)over millions of likes on every read. - The owner's username stored with each photo in a feed cache, to avoid a join per item.
- The price at the time of purchase stored on each order line, which is not even a copy but a historical fact: the product's current price will change, and the order must not.
Every denormalised copy needs an answer to "how is it kept in sync, and what if it is briefly wrong?" Options include updating both in one transaction, updating asynchronously from an event, or periodically recomputing. Microsoft describes the general approach of building precomputed read-optimised views as the Materialized View pattern, and notes that such views are typically updated after the source data changes and may be temporarily inconsistent with it (Materialized View pattern).
Normalised | Denormalised | |
|---|---|---|
Writes | Simple, one place | Must update every copy |
Reads | May need joins | Fast, fewer joins |
Consistency | Automatic | Copies can disagree for a while |
Storage | Smaller | Larger |
Default for | The source of truth | Read paths you have measured to be hot |
A good rule: normalise the source of truth; denormalise read paths on purpose.
ACID transactions in plain words
#A transaction groups several operations so they behave as one. ACID names four guarantees:
- Atomicity: all of the operations happen, or none do. A transfer that debits one account and credits another can never stop halfway.
- Consistency: the transaction moves the database from one valid state to another; constraints hold before and after. (This is a different meaning of "consistency" from the replication sense in article 19.)
- Isolation: concurrent transactions do not see each other's half-done work, to a degree set by the isolation level (article 14).
- Durability: once committed, the change survives crashes.
BEGIN;UPDATE accounts SET balance = balance - 500 WHERE account_id = 1 AND balance >= 500;UPDATE accounts SET balance = balance + 500 WHERE account_id = 2;INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 500);COMMIT;If the server crashes after the first update, atomicity guarantees the database rolls it back. (In real code you would also check that the first update changed exactly one row, and roll back if not.) Transactions inside one database are one of the strongest reasons to choose a relational store. Transactions across several databases or services are much harder; article 21 covers why.
Choosing ids
#Every row needs an identifier, and the choice affects performance, privacy and future sharding.
Id strategy | How it works | Pros | Cons |
|---|---|---|---|
Auto-increment integer | The database assigns 1, 2, 3, ... | Small (8 bytes), ordered, simple | Requires one central sequence; reveals counts ("we have 10,432 orders"); guessable; awkward across shards |
Random UUID (version 4) | 128 random bits generated anywhere | No coordination; unguessable | 16 bytes; random order scatters inserts across the index, hurting write locality |
Time-ordered UUID (version 7) | Timestamp in the high bits, randomness below | No coordination, roughly ordered, index-friendly | 16 bytes; reveals creation time |
Snowflake-style 64-bit id | Timestamp + machine id + per-machine sequence | Small, ordered, generated without a central database | Needs unique machine ids and reasonably synchronised clocks |
RFC 9562 defines UUID versions including the time-ordered version 7, which places a Unix timestamp in the most significant bits so that ids sort roughly by creation time (RFC 9562, Universally Unique IDentifiers).
Reasonable defaults: auto-increment for small, single-database systems; time-ordered ids (UUIDv7 or Snowflake-style) for systems that generate ids on many machines or will be sharded. Do not expose sequential ids publicly if counts or guessability matter; expose a separate random public id if needed. The URL shortener (article 30) and chat system (article 34) revisit id generation.
Design around access patterns
#Here is the most important idea in this article. A database is fast for the queries it is organised for, and slow for others. So the first step of data modelling is not "list the entities", it is list the access patterns:
- What does each API operation read or write?
- With which filters, in which order, how many rows?
- How often (from your estimates)?
- Which ones are latency-critical?
Then shape tables, keys and indexes so that the frequent, latency-critical patterns are cheap. Rare and offline queries can be slower, or served from a separate analytics store.
Why? Because the same data can be organised in very different ways. "Photos by owner, newest first" and "all photos tagged #sunset" and "photos liked by user 42" are all questions about photos, but each needs a different index or table to be fast. You cannot make every possible query fast; you choose the ones that matter.
Worked example 1: Pixly
#Access patterns (rates from articles 06 and 07, peak):
# | Access pattern | Type | Rate | Latency need |
|---|---|---|---|---|
A1 | Get a photo by id | Read | Very high (mostly cached) | Low |
A2 | List a user's photos, newest first, paginated | Read | High | Low |
A3 | Build the home feed for a user | Read | 3,500 per second | Low |
A4 | Like / unlike a photo; has user X liked photo Y? | Write and read | 1,700 writes per second | Low |
A5 | Like count for a photo | Read | Very high | Low |
A6 | Follow / unfollow; list who I follow | Write and read | Moderate | Low |
A7 | Monthly report of uploads per country | Read | Rare | Can take minutes |
Model:
CREATE TABLE photos ( photo_id BIGINT PRIMARY KEY, -- A1 owner_id BIGINT NOT NULL REFERENCES users(user_id), caption VARCHAR(2200), object_key VARCHAR(300) NOT NULL, status VARCHAR(12) NOT NULL, like_count BIGINT NOT NULL DEFAULT 0, -- A5: denormalised counter created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE INDEX photos_by_owner ON photos (owner_id, created_at DESC); -- A2CREATE TABLE likes ( photo_id BIGINT NOT NULL REFERENCES photos(photo_id), user_id BIGINT NOT NULL REFERENCES users(user_id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (photo_id, user_id) -- A4: one like per user per photo);How each pattern is served:
- A1 by the primary key (and the cache from article 09).
- A2 by the index on
(owner_id, created_at), which also supports cursor pagination (article 04). - A3 cannot be served well by a join at this rate; it needs a precomputed feed (article 35). Recognising that at modelling time is the point.
- A4 by the composite primary key: liking twice is rejected by the database, so the API's idempotent
PUTis easy to implement. - A5 by the denormalised
like_count, updated in batches from like events rather than on every like, accepting a few seconds of lag (article 09's write-back idea). - A6 by
follows' primary key(follower_id, followee_id), which serves "who do I follow"; "who follows me" needs a second index on(followee_id, follower_id). - A7 is analytical: run it on a replica or a separate analytics store, never on the primary at peak.
Worked example 2: an online store's orders
#Access patterns: create an order with several items; show a customer's orders, newest first; show one order with its items; staff list orders awaiting dispatch.
CREATE TABLE orders ( order_id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(customer_id), status VARCHAR(20) NOT NULL, total_paise BIGINT NOT NULL CHECK (total_paise >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE INDEX orders_by_customer ON orders (customer_id, created_at DESC);CREATE INDEX orders_by_status ON orders (status, created_at);CREATE TABLE order_items ( order_id BIGINT NOT NULL REFERENCES orders(order_id), line_no INT NOT NULL, product_id BIGINT NOT NULL REFERENCES products(product_id), quantity INT NOT NULL CHECK (quantity > 0), unit_price_paise BIGINT NOT NULL, -- price at purchase time, not a live copy PRIMARY KEY (order_id, line_no));Design notes worth saying aloud:
- Money is stored as integers in the smallest unit (paise, cents), never as floating point, which cannot represent most decimal amounts exactly.
- The unit price is stored on the line, because the product's price will change later and the order must keep what the customer actually paid.
- The order and its lines are inserted in one transaction, so an order never exists without its items.
order_itemsis keyed byorder_idfirst, so "one order with its items" reads rows that sit together, and so the table can later be partitioned by order id without splitting an order (article 16).
Connections are a limited resource
#Each application server talks to the database through connections. Opening one is expensive (network handshake, authentication, server-side setup), so applications keep a connection pool: a set of open connections reused across requests.
How large should a pool be? Use Little's Law again. If a server sends 300 queries per second and each takes 5 ms on average, it needs about 300 x 0.005 = 1.5 connections busy at a time. A pool of 10 gives plenty of headroom. Pools of 100 per server are rarely needed, and they multiply: 30 servers x 100 = 3,000 connections.
Databases do not cope well with thousands of connections. PostgreSQL, for example, runs a separate server process for each connection, so each one costs memory even when idle. Common fixes:
- Size pools from measured throughput and query time, not from guesses.
- Use a connection pooler between applications and the database (for example PgBouncer for PostgreSQL, or a managed proxy offered by your cloud provider), which lets many client connections share a smaller number of database connections.
- Keep transactions short: a connection held during a slow external call is a connection nobody else can use.
Two more things that bite in production
#The N+1 query problem
#Code that loads 20 photos with one query and then, in a loop, loads each photo's owner with another query sends 21 queries instead of 2. Object-relational mappers (ORMs) make this easy to do by accident. At 3,500 requests per second it turns into 73,500 queries per second. Fix with a join, a batched WHERE user_id IN (...) query, or the ORM's eager-loading feature, and watch the query count per request.
Changing the schema without downtime
#Adding a column, renaming one or splitting a table on a large, busy database can lock tables or break old code still running during a deployment. The safe approach is expand and contract:
- Expand: add the new column or table in a backward-compatible way (nullable, with no long lock).
- Deploy code that writes to both old and new, and backfill old rows in small batches.
- Switch reads to the new structure.
- Contract: once nothing uses the old structure, remove it.
Failure modes
#Failure | Effect | Mitigation |
|---|---|---|
Primary database down | Writes fail; reads fail unless replicas exist | Replicas and automatic failover (article 15); backups |
Connection exhaustion | New requests wait or fail even though the database is idle | Right-sized pools, a pooler, short transactions |
Missing constraint | Duplicates or orphans appear under concurrency | Enforce uniqueness and references in the database |
Long-running migration lock | Writes block; requests time out | Expand-and-contract, online schema change tools, off-peak changes |
Expensive analytical query on the primary | Latency for everyone | Run on replicas or a separate analytics store |
Disk full | Writes fail | Storage monitoring and alerts, retention policies |
Security basics for the data layer
#- Parameterised queries everywhere; never build SQL by joining strings with user input (SQL injection).
- Least privilege: the application's database user can read and write its own tables, not drop them or read other services' data.
- Encryption in transit and at rest; extra protection (or a separate store) for highly sensitive fields.
- Personal data modelled deliberately: know which columns hold it, so it can be protected, exported or deleted on request (article 28).
Trade-offs
#Decision | Option A | Option B |
|---|---|---|
Normalise versus denormalise | Correctness and simple writes | Fast reads, sync logic and possible lag |
Constraints in the database versus the application | Enforced for everyone, under concurrency | Flexible, but races and bugs slip through |
Auto-increment versus distributed ids | Small and simple | Generated anywhere, ready for sharding |
Joins at read time versus precomputed views | Always current, more work per read | Fast reads, more storage and update work |
One database versus many | Simple transactions | Scale and isolation, but cross-database consistency is hard |
Common mistakes
#- Listing tables before listing access patterns.
- Relying on application checks for uniqueness.
- Using floating point for money.
- Copying data "for speed" without a plan to keep copies in sync.
- Serving feeds, timelines and other fan-in queries with large joins at high request rates.
- Running analytics on the primary at peak.
- Huge connection pools on every server.
In an interview
#- Say "let me list the access patterns first", then write three to six of them with rough rates.
- Show the main tables with primary keys, important foreign keys and the indexes that serve the hot patterns.
- Point out one denormalisation and how it stays in sync.
- Point out one constraint that guarantees correctness (a unique key against duplicates).
- Flag any pattern that will not scale as a query, and say how you will handle it later (cache, precompute, separate store).
Exercises
#Exercise 1
Model a library
#A city library system has books (title, author, ISBN), physical copies of books at branches, members, and loans (a member borrows a copy and returns it). Access patterns: search a book by ISBN; list the copies of a book and which branch each is at, and whether it is on loan; list a member's current loans; find overdue loans each night. Design the tables, keys, constraints and indexes, including one rule that the database itself should enforce.
Exercise 2: Normalise or denormalise?
#For each, say whether you would store a copy and how you would keep it in sync:
- The author's display name shown on each comment in a comment thread.
- The number of comments on a post, shown in lists of posts.
- The delivery address on an order.
- A product's current stock level shown on its page.
Exercise 3: Pick an id strategy
#Choose an id strategy for each, with a reason:
- An internal admin tool with one PostgreSQL database and 5,000 records.
- Public order ids shown in URLs on a shopping site.
- Message ids in a chat system where messages are created on hundreds of servers and sorted by time.
Exercise 4: Connection arithmetic
#Your API runs on 40 servers. Each has a connection pool of 50. The database allows at most 500 connections. At peak, each server sends 400 queries per second with an average query time of 4 ms. What is wrong, how many connections does each server actually need, and what would you change?
Show solutionHide solution
CREATE TABLE books (book_id BIGINT PRIMARY KEY, isbn VARCHAR(13) NOT NULL UNIQUE, title VARCHAR(500) NOT NULL, author VARCHAR(300) NOT NULL);CREATE TABLE branches(branch_id INT PRIMARY KEY, name VARCHAR(200) NOT NULL);CREATE TABLE copies (copy_id BIGINT PRIMARY KEY, book_id BIGINT NOT NULL REFERENCES books(book_id), branch_id INT NOT NULL REFERENCES branches(branch_id));CREATE INDEX copies_by_book ON copies (book_id);CREATE TABLE members (member_id BIGINT PRIMARY KEY, name VARCHAR(200) NOT NULL, email VARCHAR(254) UNIQUE);CREATE TABLE loans (loan_id BIGINT PRIMARY KEY, copy_id BIGINT NOT NULL REFERENCES copies(copy_id), member_id BIGINT NOT NULL REFERENCES members(member_id), borrowed_at TIMESTAMPTZ NOT NULL, due_at TIMESTAMPTZ NOT NULL, returned_at TIMESTAMPTZ);CREATE INDEX loans_by_member ON loans (member_id) WHERE returned_at IS NULL;CREATE INDEX loans_overdue ON loans (due_at) WHERE returned_at IS NULL;-- A copy can have at most one open loan:CREATE UNIQUE INDEX one_open_loan_per_copy ON loans (copy_id) WHERE returned_at IS NULL;The important rule is the last line: a partial unique index guarantees that a copy cannot be lent to two members at once, even if two librarians check it out at the same instant. ISBN lookup uses the unique constraint on isbn. "Copies of a book and whether each is on loan" uses copies_by_book plus the open-loan index. The two partial indexes on open loans keep the nightly overdue scan and "my current loans" fast, because returned loans (the vast majority over time) are not in those indexes. (Partial indexes are a PostgreSQL feature; in a database without them, index (copy_id, returned_at) and enforce the rule in a transaction.)
Exercise 2
Show solutionHide solution
- Usually do not copy; join or cache. Names change rarely, and showing the current name is desirable. Fetch authors for the page in one batched query, and cache user profiles. If the thread is cached as a whole, the cached copy refreshes on its TTL.
- Denormalise a counter. Counting comments on every list view is expensive. Keep
comment_counton the post, incremented in the same transaction as the comment insert (or asynchronously in batches if comment rates are high), and recompute it occasionally to correct drift. - Store a copy, deliberately. The address on an order is a historical fact: if the customer later changes their saved address, past orders must still show where they were delivered.
- Do not copy for decisions. Display can use a short-lived cached value, but the purchase must check and decrement stock in the source of truth, atomically (article 14).
Exercise 3
Show solutionHide solution
- Auto-increment. One database, small scale, internal: simplicity wins.
- A random or time-ordered opaque id (for example a UUIDv7 rendered compactly, or a random public id stored alongside an internal integer key). Sequential public ids reveal order volume and let people guess other customers' order URLs (still protected by authorisation, but an unnecessary risk).
- Time-ordered ids generated on each server (Snowflake-style or UUIDv7). No central sequence to bottleneck hundreds of servers, and ids sort roughly by creation time. For strict order within one conversation, add a per-conversation sequence number (article 34), because clocks on different servers differ slightly.
Exercise 4
Show solutionHide solution
Configured pools total 40 x 50 = 2,000 connections, four times the database's limit. When load rises and pools try to grow, connections are refused and requests fail even though the database has plenty of capacity.
Actual need per server (Little's Law): 400 queries per second x 0.004 s = 1.6 connections busy on average. A pool of 5 to 10 per server covers bursts comfortably: 40 x 10 = 400 connections, within the limit.
Changes: reduce pool sizes to about 10; put a connection pooler in front of the database so future scale-outs do not multiply connections; keep transactions short; and alert on connection usage approaching the limit.
Where to go next
#- Next: Database Indexes and Query Performance: How to Make Reads Fast explains how those indexes work and what they cost.
- SQL refresher: SQL for Beginners: Learn to Query a Database With Real Examples.
- Later: concurrency and isolation (article 14), replication (15), sharding (16) and choosing a database (17).
Sources & further reading
- Technology
- Software Engineering
- #backend
- #databases
- #distributed systems
- #roadmap
- #scalability
- #SQL
- #system design
- #system design interview
- #transactions
Connected lessons
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
SQL and PostgreSQL Fundamentals for Java Developers
Guide · Technology
JPA Relationships, Lazy Loading, the N+1 Problem, Transactions and Optimistic Locking
Guide · Career
A LinkedIn Profile That Gets Recruiter Messages

