Building an On-Demand Lawyer App: The Architecture Decisions That Actually Matter
I've spent the better part of this year working on a legal consultation platform — the "book a lawyer like you book a cab" category — and I want to write down the technical decisions that turned out to matter, because almost none of them were the ones I expected going in.
The naive mental model is: marketplace app + video calls + payments. Users browse lawyers, book slots, pay, talk. I've built booking systems before; how different could it be?
Very. Legal has three properties that quietly reshape your entire architecture: privilege (attorney-client communication has legal protection, which makes logging and data handling a minefield), jurisdiction (a lawyer licensed in Texas answering a California question is a compliance incident, not an edge case), and conflicts (a lawyer can't advise both sides of a dispute — and your platform can create that situation without either party knowing).
Here's how those three properties cascaded through the stack.
The data model: jurisdiction is not a profile field
First version of the lawyer entity looked like every marketplace profile ever:
sql
-- v1: wrong
CREATE TABLE lawyers (
id UUID PRIMARY KEY,
name TEXT,
state TEXT, -- "licensed in"
practice_areas TEXT[],
hourly_rate INTEGER
);
The problem: licensure isn't a state, it's a set of (state, status, expiry) tuples. Lawyers hold multiple bar admissions, they lapse, they get suspended, they're active in one state and inactive in another. And matching logic needs to check the client's matter jurisdiction against current license status at booking time, not profile-creation time.
sql
-- v2: closer to reality
CREATE TABLE bar_admissions (
id UUID PRIMARY KEY,
lawyer_id UUID REFERENCES lawyers(id),
jurisdiction CHAR(2) NOT NULL, -- 'CA', 'NY', 'TX'
bar_number TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_verification',
-- pending_verification | active | inactive | suspended | expired
verified_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
last_checked_at TIMESTAMPTZ,
UNIQUE (jurisdiction, bar_number)
);
last_checked_at matters because bar status isn't verify-once. We re-check on a schedule (state bar websites and APIs where they exist, manual review queue where they don't), and a lapsed license has to immediately pull a lawyer out of matching for that jurisdiction — including for already-booked future consultations, which triggers a rebooking flow. That rebooking flow was two weeks of work nobody scoped.
Matching, then, is a hard filter before it's a ranking problem:
typescript
async function eligibleLawyers(matter: Matter): Promise {
return db.lawyer.findMany({
where: {
barAdmissions: {
some: {
jurisdiction: matter.jurisdiction,
status: 'active',
expiresAt: { gt: new Date() },
},
},
practiceAreas: { has: matter.category },
conflicts: { none: conflictClauseFor(matter) }, // more below
},
});
}
Conflict checking: the feature nobody puts in the pitch deck
Here's a scenario that keeps legal-tech architects up at night: a landlord books a consultation about evicting a tenant. Two weeks later, the tenant — same address, different account — books a consultation about fighting an eviction. Your matching algorithm, being helpful, routes them to the same highly-rated housing lawyer.
That lawyer now has a conflict of interest, may have to withdraw from both matters, and your platform manufactured the situation.
Real conflict checking at law firms involves databases of parties, matters, and relationships. A consultation platform needs a lightweight version, but it can't be no version:
typescript
// At intake, extract structured parties from the matter
interface MatterParty {
role: 'client' | 'adverse' | 'related';
nameNormalized: string; // lowercased, whitespace-collapsed
identifiers?: { address?: string; org?: string };
}
// Before matching, exclude lawyers with adverse exposure
function conflictClauseFor(matter: Matter) {
const adverseNames = matter.parties
.filter(p => p.role === 'adverse' || p.role === 'client')
.map(p => p.nameNormalized);
return {
matter: {
parties: { some: { nameNormalized: { in: adverseNames } } },
},
};
}
Name matching is fuzzy and imperfect — "J. Smith" vs "John Smith" vs "Smith Property LLC" — so we treat automated checks as a screen, and surface potential hits to the lawyer as a pre-acceptance disclosure: "This matter may involve a party from a previous consultation. Review before accepting." The lawyer makes the ethical call; the platform's job is making sure they can.
This is also why intake needs NLP help. Free-text matter descriptions ("my landlord at 4th & Main won't return my deposit") need entity extraction to populate that parties table without forcing users through a 12-field form they'll abandon. We run intake text through an extraction step that pulls party names, locations, and matter category, then confirm with the user in one tap rather than asking them to type it all again.
Privilege changes your logging defaults
Every backend I've built before this one logged request bodies in staging and sampled them in production. Standard observability.
You cannot do that here. Consultation content — messages, uploaded documents, video transcripts — is potentially privileged attorney-client communication. Your ops team reading it in Datadog is a confidentiality breach with legal teeth.
Concrete changes we made:
Metadata-only logging for consultation routes. Request IDs, timing, status codes, sizes. Never bodies. Enforced with a middleware allowlist, not developer discipline:
typescript
const PRIVILEGED_ROUTES = /^\/(consultations|messages|documents)\//;
app.use((req, res, next) => {
req.log = PRIVILEGED_ROUTES.test(req.path)
? logger.child({ redact: { paths: ['req.body', 'res.body'], censor: '[privileged]' } })
: logger.child({});
next();
});
Per-consultation encryption keys, envelope-encrypted with KMS, so document access is auditable at the consultation level and revocable when a matter closes.
An access-audit table that is append-only — every read of a privileged document by any principal (user, lawyer, support staff, system job) writes a row. When a client asks "who has seen my file," that question must be answerable in SQL, not in a meeting.
Support tooling with privilege walls. Customer support can see that a consultation happened, its status, and payment state. They cannot open its contents. Escalations that genuinely require content access go through a break-glass flow that notifies both parties.
None of this is exotic engineering. All of it has to be decided before the first consultation happens, because retrofitting redaction into a system that's been logging bodies for six months means your logs are already a liability.
Payments: escrow-ish, but say "held payment"
Lawyer consultations have a trust asymmetry: clients pre-pay strangers for advice of uncertain quality. The fix is holding funds until the consultation completes — authorize at booking, capture after completion, with a dispute window.
The subtlety is in the state machine, because consultations fail in more ways than rides do:
BOOKED ──▶ AUTHORIZED ──▶ IN_PROGRESS ──▶ COMPLETED ──▶ CAPTURED ──▶ PAID_OUT
│ │ │ │
│ │ │ └─▶ DISPUTED ──▶ (partial refund | capture)
│ │ └─▶ ABANDONED_MIDCALL ──▶ manual review
│ └─▶ LAWYER_NO_SHOW ──▶ VOID + priority rebook + lawyer strike
└─▶ CLIENT_CANCELLED ──▶ (full refund | fee) based on cancellation window
Two hard-won details. First, lawyer no-shows are radioactive — one no-show loses that client forever, so the void-and-rebook path is instant and the client sees it happen in real time. Second, partial outcomes are common: the call happened but ran 12 minutes of a booked 30 because the issue was simple. We settled on lawyer-initiated partial charging ("charge for 15 minutes") which lawyers use more often than I predicted — it's a trust-building tool for them, and repeat bookings correlate visibly with it.
Payouts to lawyers ride a standard split-payment rail (Stripe Connect or equivalent), but hold periods deserve thought: too short and you have no dispute recourse; too long and lawyers churn. We landed on capturing at completion and paying out after the 48-hour dispute window, with instant-payout as a fee-bearing option.
Video: buy, don't build — but own the consent layer
We use a third-party WebRTC provider (Twilio-class) for the actual calls. Building media infrastructure for a consultation product is engineering vanity.
What you must own is everything around the call: whether it's recorded at all (default: no — recording privileged communication creates a discoverable artifact and both parties must explicitly opt in), where any recording lives (your per-consultation encrypted storage, not the provider's default bucket), and retention (matter-linked lifecycle with real deletion, not soft-delete forever).
What I'd tell someone starting this build
The screens are the easy 30%. The product is the invisible machinery: license verification that stays current, conflict screening that runs before matching, logging defaults that respect privilege, and a payment state machine that handles the eleven ways a consultation partially happens.
If I were scoping it again, I'd budget the backend systems at 2–3x the client apps and staff accordingly — this is much more a fintech-adjacent compliance build than a social-adjacent marketplace build. The teams I've seen do well with this category are ones with verification/KYC and payment-state scars from other regulated domains, because every hard problem here rhymes with something from that world.
Happy to go deeper on any of these in comments — the conflict-checking design especially generated strong opinions on our team, and I suspect people here have better fuzzy-matching approaches than what we shipped.
Top comments (0)