Every integration starts as a special case. You add Salesforce, and there’s a SalesforceContact and a sync job that knows about Salesforce. You add HubSpot, and there’s a HubSpotContact and another job. By the fifth integration, product code is full of if provider === … branches, each provider has its own idea of what “connected” means, and a bug in token refresh has to be fixed five times.
TimeTackle integrates calendars with CRMs, HR tools and, more recently, legal practice-management systems, starting with Clio. Clio was the point where we decided to stop adding special cases and build a platform instead.
The two halves of the problem
An integration has two kinds of concerns that are easy to tangle:
- Domain concerns: what the data means to our product. For a law firm, a matter: a case or engagement that time is recorded against.
- Plumbing concerns: how we connect, refresh credentials, page through APIs, receive webhooks, retry failures, and know whether any of it is working.
Product features should only care about the first. Every provider shares the second. The design keeps them in separate layers.
Layer 1: a provider-agnostic domain model
The first Clio prototype had a ClioMatter entity, and it was already being referenced from tagging rules and reports. The change that mattered most was replacing it with a generic Matter entity and a MatterService, and moving everything Clio-specific behind an adapter.
export interface Matter {
id: string; // ours
accountId: string;
source: { provider: ProviderId; externalId: string };
displayNumber: string | null; // e.g. the firm's matter number
name: string;
clientId: string | null;
status: "open" | "pending" | "closed";
updatedAt: Date;
}
export interface MatterProvider {
readonly provider: ProviderId;
listMatters(conn: Connection, opts: { updatedSince?: Date; cursor?: string }): Promise<Page<ExternalMatter>>;
toMatter(raw: ExternalMatter, accountId: string): Matter;
}MatterService owns upserts, mapping by (provider, externalId), and the queries product features use. Tagging rules ask “which matter does this calendar event belong to?” and never learn which system the matter came from.
That PR got a lot of review discussion, most of it about naming and about where provider-specific fields should live. The answer we settled on: if a feature needs it, it gets a name in the domain model; if only the adapter needs it, it stays in the adapter’s own storage.
Layer 2: a shared integration platform
Under the domain model sits the plumbing every provider shares.
Connections. One model for “this account is connected to this provider”: who connected it, which credentials, scopes granted, current health. One implementation of token refresh and revocation handling. Ownership matters more than it sounds: when the person who connected an integration leaves the company, someone needs to know.
Capabilities. Providers differ in what they can do: some support webhooks, some only polling; some can write activities back, some are read-only for us. Each adapter declares its capabilities, and the platform decides what to run based on them, instead of feature code checking provider names.
export const clioCapabilities: Capabilities = {
matters: { read: true, write: false },
contacts: { read: true, write: false },
activities: { read: false, write: false }, // planned: write time entries back
webhooks: true,
incrementalSync: "updated_since",
};Lifecycle. Every integration moves through the same states: installing, initial sync, steady state, degraded, revoked, uninstalled. The platform owns the transitions and what happens on each: initial backfill, scheduling incremental syncs, registering webhooks, cleaning up on uninstall.
Feature flags. New integrations and new capabilities roll out per account. That’s how a new integration can go to a few accounts first without a separate deployment.
Observability. Each sync run records what it fetched, what it changed, how long it took and what failed, per connection. This is the baseline that lets support answer “why isn’t my data showing up?” without an engineer reading logs.
Layer 3: adapters
With the layers above in place, an adapter is small. It translates the provider’s API into the domain model and declares capabilities. The Clio adapter pulls matters (and the contacts they reference) and maps them to Matter; calendar events in TimeTackle are then tagged with those matters, so firms can see where time is going. Writing time entries back to Clio is a later phase and, when it comes, it’s a new capability, not a new architecture.
The test for the design is the next provider. Adding another practice-management system should mean writing one adapter that implements MatterProvider, declaring its capabilities, and registering it. No changes to tagging, reporting or the admin UI.
What it cost, and what it bought
It cost time up front. A Clio-only implementation would have shipped sooner.
What it bought:
- One place for each fix. Token refresh, retries, pagination, webhook verification: written once, tested once.
- Product features that don’t change when a provider is added.
- Operational visibility across all integrations at once.
- A cleaner security story. One connection model and one place where scopes and credentials are handled is far easier to review, which matters when you’re preparing for SOC 2 and when marketplaces send you their security questionnaires.
If you’re at integration number three and feeling the special cases pile up, that’s the right time to do this. By number eight it’s a migration.