I failed a system design interview. Not badly — I'd done the coding rounds fine — but when the conversation shifted from what to why, I had nothing. I'd been memorizing architectures without understanding why they existed. The interviewer noticed. The interview ended.
+ +Before I left, the interviewer gave me advice I've been turning over ever since:
+ +Find a problem that's yours. Something in your world. Then run the full loop: figure out the problem → design it → build it → ship it → see what breaks → iterate. That cycle, done on something you care about, compounds fast.+ +
That's how Partida started.
+ ++ +
The Problem
+ +My family had been thinking about relocating abroad. When I started researching what that actually involved, I was overwhelmed. The Portugal D7 visa alone requires a valid passport, two EU-format passport photos, a signed application, an FBI background check with apostille, proof of accommodation, proof of sufficient funds, travel health insurance, a flight itinerary, power of attorney forms, NIF application, a personal statement — and the list keeps going. Multiply that by a spouse, maybe kids, and you're looking at three or four times the paperwork, each document with its own expiration date and cross-dependencies.
+ +The problem wasn't finding the information. It was tracking it. There was no visual progress — just a pile of requirements and a gnawing sense that something was being forgotten. A spreadsheet helps, but it doesn't tell you that your FBI background check expires before your consulate appointment, or that your spouse's apostille is blocking your joint application.
+ +That's what Partida solves: a document tracker that understands dependencies, surfaces blockers, and shows a family's real progress instead of just a list.
+ ++ +
Design from First Principles
+ +Coming off the interview, I noticed a bad habit in myself: I'd reach for patterns I'd read about rather than patterns the problem required. Redis cache to show I know caching. Kafka because I'd studied it. I was designing for an imaginary interviewer, not for the actual problem.
+ +With Partida, I forced myself to start with questions instead.
+ +What actually needs to be tracked? Documents and people. That's it. A person has documents. Documents have statuses. Everything else is decoration.
+ +How many users? At first, just me and my spouse running locally. But if it actually works, others will want it — some self-hosting, some on a shared instance. That means designing for one user today without painting myself into a corner for tomorrow.
+ +What's the read-to-write ratio? Writes are rare — status updates, document uploads. Reads dominate. Someone checking their tracker obsessively tells me reads will far outnumber writes. Cache might matter someday. Not yet.
+ +What breaks first? The database gets corrupted, or I make a schema change and break existing data. One is solved with backups. The other requires thinking carefully about every migration before running it.
+ +Starting with these questions meant the architecture that emerged was earned, not assumed.
+ ++ +
The Prototype
+ +Version zero was intentionally minimal: Python dictionaries as an in-memory database. Two dicts — personal_documents for member-specific documents and shared_documents for family-wide ones. FastAPI served them. cURL tested them.
personal_documents = {
+ 1: {
+ "id": 1,
+ "title": "passport",
+ "family_member_id": "primary",
+ "depends_on": [],
+ "status": "not_started"
+ },
+ # ...
+}
+
+shared_documents = {
+ 39: {
+ "id": 39,
+ "title": "Proof_of_Accommodation",
+ "family_members": ["primary", "spouse", "child1", "child2"],
+ "depends_on": [],
+ "status": "not_started"
+ },
+ # ...
+}
+
+ The tradeoffs were obvious and accepted. No persistence meant no real users. No SQL meant no complex queries. But it proved the logic worked: documents could have owners, statuses, and dependencies. That was enough to move forward.
+ +The moment the core was solid, I replaced the dictionaries with a real database and wired up a proper frontend. The prototype had done its job.
+ ++ +
The Stack
+ +SQLAlchemy + PostgreSQL
+ +The data is naturally relational: people have documents, documents have statuses, documents depend on other documents. A relational database was the right call, not a trendy one.
+ +SQLAlchemy specifically because I could stay in Python. No context-switching to raw SQL while trying to move fast. The ORM let me define my schema as Python classes, and the relationship mapping matched the way I was already thinking about the data. Type hints integrated naturally with my API layer. For a schema that's stable and read-heavy, it fit without friction.
+ +FastAPI
+ +I considered Flask. Five lines of code, working API in minutes — beautiful in its simplicity. But data validation kept coming up. With Flask I'd be stitching together Marshmallow for serialization, something else for docs, something else for type checking. FastAPI gives Pydantic, automatic OpenAPI docs, and native type hints out of the box. I knew I'd end up there eventually, so I started there instead.
+ +HTMX + Jinja2
+ +My frontend instincts lag behind my visual ambitions. I know what I want things to look like — making them look that way is the problem. I didn't want to build a React app. I wanted the server to stay authoritative and the frontend to stay simple.
+ +HTMX does one thing: it lets any HTML element make an HTTP request and swap the response into the page. No build step. No framework. Attributes like hx-post and hx-swap handle what would otherwise require a full client-side state management layer. My FastAPI routes return HTML fragments directly — the server stays the source of truth, HTMX handles the dynamic updates.
It felt like magic at first. Then I realized it's just HTML being allowed to do what hypertext always could — link to and load other content — but with more flexibility about where it lands on the page.
+ ++ +
First Deploy
+ +I ran it. 500 error.
The database tables existed. The schema was right. The frontend was failing because there was no data. I'd spent so much time modeling the structure — people, documents, statuses, dependencies — that I forgot the app expected rows to already exist. Tables without rows are useless.
+ +I wrote a seed script: family members, 50+ documents, initial statuses (all not_started), and the dependency relationships between them. Ran it. Refreshed. Everything worked. Then I closed the terminal and reopened it — data still there. Then I updated a status mid-session — UI updated. Then I tried to break it on purpose.
I found one real issue: when you add a new table or column, SQLAlchemy's create_all() doesn't help you. It creates tables that don't exist, but it won't migrate data in tables that do. I needed migrations.
+ +
Adding Alembic Mid-Stream
+ +Most migration tutorials assume a fresh project. Mine wasn't. Tables already existed. Alembic had no idea where to start.
+ +The first issue was a split Base. My __init__.py was importing Base from two different model files:
from .document import Base # first import
+from .learning import Base # silently overwrites the first
+
+ Alembic only saw the models attached to the second Base. The fix was a single shared base.py that every model imported from — one Base to rule them all.
The second issue was schema drift. When I ran the first autogenerate migration, it wanted to drop two tables — document_dependencies and family_document_dependencies — stale junction tables still in the database but removed from the models. The database and the code had gotten out of sync, and Alembic was faithfully reflecting that.
The fix was stamping a baseline — telling Alembic "the database is already here, start tracking from this point" — then generating a migration to clean up the stale tables:
+ +alembic stamp base
+alembic revision --autogenerate -m "drop_stale_dependency_tables"
+alembic upgrade head
+
+ Every migration after that followed the same discipline: one model change, one migration, review the generated upgrade() and downgrade() before running anything. Alembic's autogenerate is good but not perfect — it even says so in the comment it leaves in every file:
# ### commands auto generated by Alembic - please adjust! ###
+
+ That's not a disclaimer. It's an instruction. The migration chain for Phase 1 ended up clean and linear:
+ +base
+ └── drop_stale_dependency_tables
+ └── add_household
+ └── add_user
+ └── refactor_family_members_to_members
+ └── add_household_invites
+
+ + +
Scaling the Data Model
+ +The original data model worked perfectly — for one family. The FamilyMembers table used a SQLAlchemy enum as its primary key:
class FamilyMemberType(enum.Enum):
+ PRIMARY = "primary"
+ SPOUSE = "spouse"
+ CHILD_1 = "child_1"
+ CHILD_2 = "child_2"
+
+class FamilyMembers(Base):
+ __tablename__ = 'family_members'
+ id = Column(SQLEnum(FamilyMemberType), primary_key=True)
+ display_name = Column(String, nullable=False)
+
+ The problem: "primary" is now a unique value across the entire database. When a second family signs up and tries to register their primary applicant, the database rejects it — a row with id = "primary" already exists. There's no way to tell two primaries apart.
The fix required rethinking the entire membership model. I introduced a Household as the top-level grouping, replaced the enum primary key with a standard integer ID, and separated the concept of a member (a person with documents) from a user (someone who logs in):
class Household(Base):
+ # top-level grouping — visa type, timeline, name
+
+class Member(Base):
+ # person in a household — has documents, a role, may or may not login
+ # integer PK, household_id FK, role as a plain column
+
+class User(Base):
+ # the login account — authentication only
+ # separate because a child is a Member but not a User
+
+class HouseholdInvite(Base):
+ # token-based invite for partners to join an existing household
+
+ The permission model followed naturally: adults default to admin (editors), children are always viewers. At least one active admin must remain in a household at all times — pending invites don't count toward that threshold.
+ +This is the design decision that took the most deliberate thought. It would have been easy to just add a household_id column and call it done. But separating Member from User matters — it correctly models the real world, where a three-year-old has documents but doesn't have login credentials. Getting that right early means the auth layer, the permission system, and the onboarding flow can all be built on accurate foundations.
+ +
Where It Stands
+ +Partida is live and in beta with 15 families. 2 attorney partners have been involved in feedback. The tracker covers 19 case types with full dependency modeling — if your apostille expires before your consulate appointment, the tracker knows, and it tells you.
+ +12 deploys a week on Railway. FastAPI + SQLAlchemy async on the backend. HTMX + Jinja2 on the frontend. Firebase Auth for authentication, compliance-first from the start. Claude for architecture reviews and AI-assisted iteration, which cut feature development time meaningfully.
+ +The stack isn't exotic. It was chosen by asking the right questions first, building the simplest thing that could prove the idea, and earning complexity when the problem actually demanded it. That's the whole lesson from the interview I failed.
+ +