Engineering Notes

Building Docket with Pragmatic DDD

A code-based look at how Docket uses bounded contexts, domain services, and a modular monolith to manage a professional-services workflow—and how the architecture can evolve.

HOUHUIYANG.COM

Scan to continue reading

Generating…

Building Docket with Pragmatic DDD

houhuiyang.com/en/notes/building-docket-with-pragmatic-ddd

Docket is a workspace for lawyers and professional-service teams. It connects lead acquisition, document collection, agreements, delivery, payments, reviews, and referrals. The hard part is not CRUD. It is keeping rules consistent across roles, stages, and side effects while the product continues to change.

I built it around the core ideas of domain-driven design without reproducing a textbook directory structure. The precise description is a modular monolith organized around bounded contexts: one deployable system and local transactions, with business boundaries used to contain complexity.

Docket's pragmatic DDD architecture

Start with business boundaries

Page-based decomposition produces “project page” and “admin page” modules. Table-based decomposition produces a collection of CRUD services. Neither tells us where a business capability begins and ends.

Docket instead separates Identity, Lead, Project, Collection, Agreement, Delivery, Finance/Billing, Profile/Portal, and Notification. Each context owns a distinct language and reason to change. Agreement owns signing rules; Collection owns upload and review; Project owns the collaboration lifecycle. Notification supports them but does not make their decisions.

Docket bounded-context map

ContextResponsibilityRepresentative objects
IdentityAccounts, authentication, plan identityLawyer, LoginLog
LeadLeads, follow-ups, conversionLeadEntry, LeadFollowUp
ProjectProject lifecycle and collaborationProject, ProjectItem
CollectionUpload, review, acceptanceDocumentFile, ReviewService
AgreementMulti-party signingProjectAgreement, AgreementSigner
DeliveryDeliverables and fulfillmentDelivery, DeliveryPhoto
Finance / BillingFees, payments, plans, usageFeeRecord, PaymentEntry, CoinAccount
Profile / PortalAcquisition pages and client accessLawyerProfile, PortalProjectService

Three responsibilities in the codebase

Flask's application factory composes three visible layers.

app/api/*_api.py contains Blueprints and owns HTTP concerns: authentication, input parsing, service invocation, and responses. It should not decide when a project is complete.

app/{domain}/services.py implements use cases and business rules. Collection handles validation, batches, and review; Agreement coordinates invitations, rejection, completion, and result PDFs; Portal composes client-facing views. Today these services combine application orchestration with parts of the domain layer.

app/{domain}/models.py contains SQLAlchemy entities, relationships, and domain-flavored enums. MySQL, email, files, scheduled tasks, AI providers, and blockchain notarization supply infrastructure capabilities.

Next.js
  → Flask Blueprint: protocol and authorization
  → Domain Service: use case and rules
  → ORM / db.session: state and transaction
  → Notification / File / Scheduler: side effects
  → JSON response

The useful constraint is directional: pages do not understand storage, APIs do not duplicate business rules, and domain modules do not depend on presentation details.

Aggregates are consistency boundaries

Project is the clearest aggregate entry point. External clients address it with public_id; integer keys remain internal for joins. ProjectItem and DocumentFile change through explicit use cases under ownership, authorization, and state constraints.

Anonymous flows use separate capability tokens. A collection client_token and an agreement signer_token grant access as well as identify a resource, while a public_id still requires JWT authorization. Keeping those concepts separate is a domain security rule, not cosmetic URL design.

Coordinating contexts without premature distribution

A customer journey crosses contexts: a lead becomes a project, the client uploads documents, a lawyer reviews them, parties sign, work is delivered, and a review may create a referral.

Docket currently coordinates this through explicit service calls and same-database transactions. That keeps deployment and debugging direct. Email, PDF generation, scheduled work, and notarization are separated as supporting services or queued tasks where their failure should not obscure the primary decision.

The important rule is ownership. Agreement determines whether signing is complete. Project may react to that fact, but should not reproduce the signing algorithm. Contexts exchange identifiers and outcomes, not fragments of duplicated rules.

Why bounded contexts do not imply microservices

All Docket contexts currently share a Flask process and MySQL database. Local transactions are valuable across project, collection, and notification work, while distributed calls, message consistency, and operational infrastructure would add cost before they add leverage.

A modular monolith only works when the boundaries are real: independent directories, clear entry points, and explainable data ownership. Service extraction should follow evidence such as independent scaling, fault isolation, team autonomy, or regulatory isolation—not the number of modules on a diagram.

This is not “pure DDD”

The implementation deserves an honest label. Services directly use SQLAlchemy models and db.session; persistence models also act as domain entities; some services serialize responses and query across contexts. Repositories, ports, and domain events are not universal abstractions.

That is a deliberate trade-off during rapid discovery. Clear language and module boundaries are more valuable than speculative interfaces. As complexity justifies it, the design can evolve incrementally:

  1. Move critical state machines and invariants from large services into entities or domain policies.
  2. Define ports for file storage, email, AI, and notarization, with infrastructure adapters behind them.
  3. Introduce application DTOs so API representation no longer leaks into domain services.
  4. Publish domain events for agreement completion, project acceptance, and lead conversion; use an outbox for reliable delivery.
  5. Enforce module dependency rules and prohibit direct writes into another context's data.
  6. Extract a service only when its runtime boundary is genuinely different.

How to test whether the boundaries work

The diagram is not the acceptance criterion; change cost is. A useful boundary passes three tests: a rule change stays mostly inside one module, a business action has one obvious entry point, and a failure can be attributed to a context, use case, and side effect.

Tests should mirror those boundaries: domain tests for invariants and transitions, service tests for use cases and transactions, API tests for authentication and contracts, and a small set of end-to-end tests for the journey from acquisition to delivery.

The lasting lesson

DDD is valuable when code structure follows business structure. For Docket, the decisive questions are not whether folders are named domain, application, and infrastructure. They are: who owns project state, who accepts a document, who completes an agreement, who owns delivery, and how facts cross those boundaries.

Docket establishes those boundaries in a modular monolith first, then lets architectural evidence drive further separation. The goal is not ceremonial purity. It is to ensure that every increase in complexity still has a clear, testable, and evolvable home in the business model.

Project

Back to Engineering Notes