OneSource · Xenspire Group · Summer 2026
Flow360: breaking out of the applicant tracking mental model.
Flow360 is the internal platform OneSource's recruiters use to run their work. I found the model the business was actually operating in, drew it so the team could see it, and owned the refactor that moved a live codebase onto it.
- FastAPI · SQLAlchemy · Alembic
- PostgreSQL
- Next.js · React
- Existing multi-engineer codebase
- Shipped to production
- Problem
- Flow360 had grown into a copy of a standard applicant tracking system (ATS): jobs with a linear pipeline. But the business doesn't work in one line. People sit in buckets, like a job, a talent pool, or a campaign, and move between them. New kinds of buckets kept appearing faster than developers could hardcode them.
- What I did
- Modeled the business as typed objectives, made architecture diagrams to get the team aligned, and owned the frontend and backend refactor that moved the existing system onto that model, including migrating production data without stopping the app.
- Hardest decision
- Where to break cleanly and where to keep legacy. I kept the existing Job Order table and its integrations, but replaced loose objective-to-job links with strict one-to-one ownership, and turned "which bucket is this person in" into an enforced record.
- How I tested
- Rehearsed the migration on a full production copy (100,000+ person records). It surfaced 7,117 workflow cards pointing at positions that no longer existed. I repaired them, found the deletion bug that caused them, and added foreign keys so it can't happen again.
- What I learned
- UX is a test of the model. If a recruiter has to create a job, then create its objective, then pick that objective again from a menu, the abstraction is leaking into the product.
The pattern I noticed
Recruiting software is usually built around one shape: a job, and a line of stages a candidate moves through until they're hired or rejected. That's an ATS. Flow360 had become one.
But when I looked at what the business actually did all day, most of it didn't fit a line. We keep a network of great people we're not placing right now. We run outreach to companies that might become clients. A person can be on two jobs at once, and when a job closes, they shouldn't just vanish. The common thread wasn't "pipelines." It was that people always belong to some bucket, and some buckets are going somewhere while others aren't.
The team already had an early idea called "objectives." The problem was that the code still treated them through the ATS model, and the business was moving faster than the code could follow. So I stepped in, spent a lot of time understanding the model, drew it out, and then worked on bridging the gap between the diagrams and the code.
Before: objectives as loose hubs
The original objectives worked like folders with sticky notes pointing at jobs. A folder could point at several jobs. A person could be in many folders with no idea which one was "current," or in none at all. A talent pool, which has no finish line, still had to pretend to be a pipeline. And recruiters saw both "Job Orders" and "Objectives" in the sidebar, which were really the same thing seen twice.
After: three kinds of objective
Here's the metaphor I used. Every person is always standing somewhere, in one of three kinds of place:
The point of the abstraction is that last column. A marketing campaign and a job order look unrelated, but they're the same shape: move people along a belt toward an outcome. Build that shape once and the business can reuse it anywhere. Users never see the word "objective," though. They see a Job Order, or a Campaign, because those are the things they understand.
The membership rules live in PostgreSQL, not just the app, so every write path follows them: a person can be on several targets in the same group, but an ongoing or terminal objective is exclusive. Entering one closes their seats on targets in the same transaction. Each move is recorded with who did it and why, so "where is Jane?" always has an answer, and so does "how did she get there?"
Pieces, not features
The second goal was to stop needing a developer every time the business wanted a new kind of bucket. Businesses iterate faster when the people running them can change the process themselves. So the approach is: we build the pieces, and admins put them together. Workflows already have editable stage names on top of stable IDs, so renaming a stage can't break where candidates are sitting.
The next step, which I built after the main refactor and is still in development, is an admin catalog where non-developers define new objective types and their custom fields. Today it supports creating new ongoing and terminal types; arbitrary new target types are next. Native fields stay in their real columns, and custom ones go into typed value rows, so the existing Job Order queries didn't have to change.
Bridging into a live system
None of this was a rewrite. Flow360 had real data, integrations, and other engineers changing it at the same time, so the model had to be moved in underneath a running product. I worked through it in extended sessions with the team and owned the workstream between them.
The rehearsal was the most valuable part. Checking that the new tables existed would have passed. Checking the actual invariant, "every card points at a real stage on its template," found 7,117 that didn't. Rather than just fill in the blanks, I traced why: the app's delete check counted cards owned by objectives but missed cards owned by Job Orders, so stages were being deleted out from under people. I fixed the check and added restrictive foreign keys, so the database itself now refuses to create that kind of damage.
I also wired outreach sequences to the workflow: a sequence starts when a candidate reaches one stage and stops when they reach another, with recruiter approval when needed, and finished sequence steps stop showing up as outstanding tasks.
The code was still thinking in pipelines. The business was thinking in buckets. My job was to find the model that fit the business, then carry it all the way into the code.