Field Operations Industry, chapter 23 of 26
Platform Architecture
Structuring a field operations platform: domains that own their data, events and projections, shared vocabulary, roles and scopes, audit, addresses and time.
Every chapter before this one describes a piece of the business: the account, the agreement, the schedule, the route, the technician's app, the license, the invoice. Each of those pieces has its own rules, its own people and its own pace of change, and the platform that runs them has to keep them consistent without tying them into one knot. A sale at the door has to become a customer, a subscription, a first appointment and a billing schedule within seconds, and a failure in billing must not stop a technician from finishing a service. This chapter is how to structure that. The general practice is in Service Boundaries, Events & Async Messaging and Authorization; what follows is what field operations adds.
The Domains#
A field operations business divides naturally along the lines of who does the work. Each line becomes a domain that owns its data outright:
| Domain | Owns | Publishes, for example |
|---|---|---|
| CRM | Accounts, the people on them, contacts, notes, alerts | account.created, customer.merged, flag.created |
| Sales | Territories, teams, reps, knocks, pricing rules, agreements | agreement.sent, agreement.completed, agreement.cancelled |
| Field service | Geography, schedules, work orders, appointments, routes, shifts, material records | appointment.completed, route.optimized, weather_incident.declared |
| Billing | Billing customers, subscriptions, invoices, payments, payment methods | invoice.finalized, payment_method.blocked, subscription.frozen |
| Communications | Messages, templates, consent, delivery state | message.sent, customer.message_opt_status_changed |
| Licensing | Jurisdictions, requirements, licenses, exemptions, compliance | license.expired, user_compliance.changed |
| People | Employees and their lifecycle, synced from HR | employee.hired, employee.terminated |
| Platform | Identity, roles, branches, addresses, feature flags, documents | user.created, service_center.updated |
The split follows the teams who use each one (Service Boundaries): sales leadership changes pricing rules weekly, routing analysts tune the optimizer daily, finance changes collections policy quarterly, and none of them should need a release from another team to do it. It also follows failure: when the routing engine is down, the office can still take payments; when the payment processor is down, technicians can still complete services and the invoices queue.
One Owner, No Shared Tables#
The rule that makes the split real is simple and strict: a domain never reads another domain's database and never calls another domain's API to do its own work. Every piece of data has one owner (Every Piece of Data Has One Owner), other domains learn about it from events, and the only exception is the platform domain, whose identity, branch and address services everything else depends on by design.
The temptation to break the rule is constant, because the data is so connected. A route sheet needs the account's name. A billing decision needs to know whether the first service happened. A sales rep's dashboard wants to know which of their sales have been serviced. A synchronous call for each of these is easy to write, and each one couples two release schedules and two failure modes. A route sheet that can't print because CRM is slow is the cost of that shortcut, discovered at 7 a.m. with forty technicians waiting.
Events Carry the Business#
The connections between domains are events, and the most important event in the business shows why. When a customer signs at the door, Sales publishes agreement.completed, and four domains act on it independently:
- CRM creates the subscription record on the account and turns the pests the rep noted into an alert the technician will see.
- Billing creates the billing subscription with the agreed prices, frequency and preferred billing day, waiting to start until the first service is done.
- Field service converts the appointment the rep reserved during the sale into a confirmed one, guarding against creating it twice.
- Communications sends the welcome and schedules the reminder.
The mechanics are the playbook's, applied without exception:
- Versioned schemas, validated centrally. Each event type has a schema named by domain, resource, action and version (
sales/agreement.completed.v1), and a central registry validates every event against it. An event that fails validation is stored as failed and alerted on, not delivered; a consumer should never be the first to discover a malformed event (Schema Evolution). - A transactional outbox. The domain writes the event in the same transaction as the change, and a relay publishes it (The Transactional Outbox). A relay polling an outbox table with
FOR UPDATE SKIP LOCKEDlets several workers drain it without waiting on each other, which PostgreSQL documents as suitable for queue-like tables. - At-least-once delivery and idempotent consumers. Brokers retry: Amazon EventBridge, for example, retries a failed delivery for 24 hours and up to 185 times by default before dropping it or sending it to a dead-letter queue. A relay can also publish the same message twice if it crashes between sending and recording that it sent. So every consumer records what it has processed and treats a repeat as a no-op (Consumers Are Idempotent).
- Out of order is normal. The CRM listener for
agreement.completedcan run before the listener foragreement.senthas created the account. The consumer recognizes a missing dependency, raises a transient error and retries, rather than failing permanently or creating a placeholder that will conflict later.
Events are also how the business sees itself. An operations wall board that shows technicians arriving on site, services completing and sales closing across every branch, in real time, is a consumer like any other: it subscribes to appointment.started, appointment.completed and agreement.completed, and shows technician and city, never the customer.
Projections, Not Lookups#
A domain that needs another domain's data keeps a projection: a small, read-only copy of exactly the fields it needs, filled from events. Field service keeps the account name and the alert flags for the route sheet and the technician's app. CRM mirrors the billing subscription's status so an agent can see "past due" without asking Billing. Sales keeps each agreement's serviced and cancelled state for rep reporting.
Projections have three rules:
- They are never written by the domain that holds them, except by the event handler. If field service needs to change an account's name, it asks CRM, and its copy changes when CRM's event arrives.
- They are allowed to be stale, and the product is designed for it. A technician who sees a flag a minute late is fine; a payment decision made on a projection is not, which is why billing decisions stay in Billing.
- They can be rebuilt. A projection that drifts, or a new one, is filled by replaying events or by a one-time backfill from the owner's API, so a bug in a handler is recoverable without hand-editing.
Unknown values are stored, not rejected. When Billing introduces a subscription status the CRM projection has never seen, CRM stores it verbatim and logs a warning; a projection that crashes on a new enum value takes down the consumer for a change it didn't need to understand.
Words That Mean Different Things#
The hardest integration problem is vocabulary. Martin Fowler's description of bounded contexts names "Customer" and "Product" as the polysemes that cause the same confusion over and over, and a field operations platform has all of them:
| Word | In one domain | In another |
|---|---|---|
| Customer | CRM: a person, who can belong to several accounts | Billing: the billing entity, one per account, that holds payment methods |
| Subscription | Sales: the signed agreement, with its terms and contract value | Billing: the recurring charge. Field service: the schedule of services owed |
| Office | Legacy systems: the unit everything is filed under | The new platform: a service center in a region, with areas and zones |
| Appointment | Field service: a calendar slot on a route | Billing: the trigger for an invoice. Communications: the subject of a reminder |
| Cancel | An appointment cancelled and rebooked | An agreement cancelled within its rescission period, or a subscription cancelled after years |
Two practices keep this manageable. Write the glossary down and use its terms in event names and APIs, so subscription in a Billing event never means the agreement. And share identifiers across domains where they denote the same thing: when the billing subscription, the CRM subscription and the field service subscription all carry the same ID, generated once when the agreement completes, a support agent can follow one ID through every system without a mapping table.
The Branch Is the Partition#
Field operations companies are organized geographically: regions contain branches (service centers), branches contain the areas they serve, and areas divide into zones for routing (Territories & Capacity). That hierarchy partitions almost everything: who can see which customers, which technicians can be routed where, which numbers roll up into which manager's report.
It is not multi-tenancy. A tenant is a customer of your platform whose data must never mix with another's (Multi-Tenancy); a branch is a part of one company, and its boundaries are permeable on purpose. A customer moves across town into another branch's area. A technician covers for a neighboring branch during a busy week. A regional manager sees every branch in the region. So the branch is an attribute on records and a scope in permissions, not a wall in the database. If you do sell the platform to other companies later, the tenant sits above the region, and the branch hierarchy lives inside each tenant.
Branches also carry configuration that the rest of the system reads: timezone, holidays, the distance a technician may be sent from home, whether in-day re-optimization is on, which licenses the branch holds, which payment account it settles to. Treat that configuration as data with history, because "what was the rule at this branch when this happened" is a question audits and disputes ask.
Identity, Roles and Permissions#
A field operations company has an unusual staff population. Hundreds of technicians who work from a phone in a truck. Dispatchers and route planners in each branch. A customer service center. Finance and collections analysts. Compliance managers who track licenses. And, in companies that sell door to door, a seasonal sales force that can double headcount for a summer and leave by autumn, organized as a tree of regional, divisional and team leaders over reps and recruits.
That population shapes identity:
- Provision from HR, deprovision from HR. Accounts are created when the HR system says someone was hired and disabled when it says they left, through SCIM or a sync, so a terminated technician's tablet stops working the same day (SSO & Federation). Seasonal reps need the same discipline, at much higher volume, every spring and autumn.
- Permissions as action, resource and scope. NIST's model of role-based access control assigns users to roles and permissions to roles; the useful refinement here is that each permission names an action, a resource and a scope:
read:accounts:branch,write:routes:branch,close:flags:*,restore:subscriptions:*. Deleting an account or merging two is its own permission, held by few. - Scope follows the organization. A technician sees their own route and the customers on it. A dispatcher sees their branch. A regional manager sees the region. A sales leader sees their downline, the reps under them in the tree, and nobody else's; a dealer admin sees their partnership. These are relationship checks, not role checks, and they belong in one policy layer, not scattered through handlers (Choosing a Model).
- Roles named for jobs. Technician, office manager, dispatcher, route planner, customer service representative and supervisor, AR analyst, compliance manager, each sales rank, recruiter, recruit. A role is a bundle of permissions maintained centrally, and a job change is a role change, not a set of individual grants.
- Customers are a separate population. A household logs into the customer portal with a one-time code, and its members have their own roles on the account (owner, manager, editor, viewer). Staff can view the portal as the customer to help them, which is an access recorded with who and why (Staff and Support Access).
Capability is not only a role. Whether a rep may close a sale at a given address, or a technician may apply a product in a given county, depends on their licenses in the jurisdictions that contain that address (Licenses & Permits). That check belongs to the licensing domain, which publishes each person's capabilities per jurisdiction; sales and field service consume them like any other projection.
Audit: Who Changed What#
Two different questions get called "audit," and they need two different records.
- What happened to this customer? A timeline across domains: the sale, the first service, every appointment, payment, message and note. The event store answers it: index events by the entity IDs they carry (an account ID, a customer ID, a subscription ID) and the timeline is a query, not a reconstruction.
- Who changed this, and from what? A change log with the actor, the time, the before and after, and the reason. OWASP's guidance for audit logs is to record when, where, who and what for each event, and to protect stored logs from tampering. Write audit records to an append-only store outside the application's database, where the people whose actions are logged can't edit them.
Some histories are part of the domain itself and deserve their own tables: every change to a route (who moved which stop, when), every consent change (each a new record, the old one kept), every license status change. These are the records a regulator, a court or an angry customer will ask for, and they should never depend on log retention.
Addresses and Time#
The address is the most shared thing on the platform. Sales knocks on it, CRM files the account under it, field service routes to it, licensing decides which jurisdictions contain it, billing calculates tax and chooses a merchant account from it, and communications decides when it may text it. So the address belongs to the platform domain, and every domain refers to it by ID:
- Validated and geocoded once. Standardize through an address verification service, store the latitude and longitude, and keep the validation source and date.
- Deduplicated by fingerprint. A hash of the normalized address finds the existing record before creating a new one. Normalize carefully: a ZIP+4 and a five-digit ZIP for the same house should match.
- Carrying a timezone. Each address stores its IANA zone, which IANA describes as a database of the history of local time, updated as governments change boundaries and rules (Time & Date Formats).
The property's local time governs most of the business: arrival windows are in it, a billing cycle anchored to "the 15th" means the 15th where the customer lives, texting quiet hours apply where the recipient is, and a rescission period counts business days in the buyer's zone. Store future appointments as local time plus zone, not as UTC, so a change to daylight-saving rules doesn't move every appointment by an hour (Future Wall-Clock Times). Reports, in contrast, use the branch's time: "today" for a branch dashboard ends at the branch's midnight, even for an address across a timezone line.
Automations#
A large share of the business is "when this happens, do that" or "every night, do this": send a reminder a week before each appointment; text the customer if the visit is cancelled; mark invoices uncollectible after a period unpaid; export the collections file; remind a technician thirty days before their license expires. The temptation is to write each one as a hard-coded listener. The better structure is a workflow engine whose definitions are versioned data:
- Triggers are events, schedules or a person.
appointment.created, a nightly cron in the branch's timezone, or a manual run with typed inputs. - Each workflow is idempotent and deduplicated. A reminder workflow checks that no reminder for this appointment is already queued; a cancellation text checks that a reminder was actually sent, so nobody gets "your visit was cancelled" for a visit they never heard about.
- Future actions are retractable. A reminder queued for next Tuesday is deleted when the appointment is rescheduled, cancelled or its customer opts out. Every domain event that invalidates a scheduled action has a handler that removes it.
- Runs are recorded. Each run, job and step has a state (pending, running, completed, failed, cancelled, skipped) and logs, so "why did this customer get that text" has an answer.
Keep money and compliance decisions in the domains that own them, and let workflows call those domains' APIs. A workflow that computes a late fee itself has become a second billing system (Background Jobs).
What This Means for the System You Build#
- Domains with one owner per piece of data, and no cross-domain database reads or synchronous calls for core work.
- Versioned, centrally validated event schemas, published through an outbox and consumed idempotently, tolerant of duplicates and disorder.
- Read-only projections filled from events, rebuildable, and tolerant of values they don't recognize.
- A written glossary and shared IDs for the same thing across domains.
- The branch hierarchy as an attribute and a permission scope, with branch configuration kept as data with history.
- Identity provisioned and removed from HR, permissions as action, resource and scope, relationship scopes for downlines and branches, and recorded staff access to customer views.
- An append-only audit log outside the application, plus domain histories for routes, consent and licenses.
- Addresses as a platform service: validated, geocoded, deduplicated, with an IANA timezone, and local time for anything the customer experiences.
- A workflow engine for event and schedule triggers, with deduplication, retractable future actions and recorded runs.
References#
Specifications
- IANA: Time Zone Database, the history of local time by location, updated as political bodies change zones and rules
- NIST: Role Based Access Control, users assigned to roles and roles to privileges, standardized as ANSI/INCITS 359
Reference
- Martin Fowler: Bounded Context, separate models per context, and "Customer" and "Product" as recurring polysemes
- microservices.io: Transactional outbox, writing messages in the business transaction, and a relay that can publish twice
- PostgreSQL: SELECT, the locking clause,
SKIP LOCKEDfor multiple consumers of a queue-like table - OWASP: Logging Cheat Sheet, recording when, where, who and what, and protecting logs from tampering
In practice
- Amazon EventBridge: Retry policy, retries for 24 hours and up to 185 attempts by default
- Amazon EventBridge: Dead-letter queues, keeping events that couldn't be delivered for later processing