ENGINEERING

6 Services Extracted: Our Service Layer Architecture

Andres MuguiraFebruary 26, 20268 min read
ArchitectureReactRefactoring
← Back to Blog
Summarize with AI

The Problem: Components Doing Everything

Before: a single 1,200-line ContactDetail.tsx file handling queries, business logic, UI rendering, and error handling.

Six months into building SalesSheet, our React components had become monsters. The ContactDetail component was 1,200 lines long. It fetched data, transformed it, managed local state, called Supabase directly, handled errors, and rendered the UI. Every new feature added more logic to the same component. Testing was nearly impossible because you could not test the business logic without rendering the entire component tree.

We knew we needed a service layer -- a set of plain JavaScript modules that own the data access and business logic, with components reduced to thin UI shells that call service methods and render the results. The question was how to extract six services from a running application without breaking anything.

A 1,200-line React component is not a component. It is a small application pretending to be a UI element.
After: thin UI components calling 6 extracted service modules with clean interfaces and zero duplicated queries.

The Service Pattern

Every service in SalesSheet follows the same pattern. It is a plain JavaScript module that exports async functions. Each function handles one operation: fetch, create, update, delete, or a domain-specific action. Services own the Supabase client calls, error handling, and data transformation. Components never call Supabase directly.

The pattern looks like this:

Service 1: activitiesService

The activities service manages the timeline of events on a contact or deal: calls logged, emails sent, notes added, stage changes, tasks completed. Before extraction, activity fetching and creation logic was duplicated across four components: ContactDetail, DealDetail, ActivityFeed, and the AI chat handler.

What It Owns

The most complex method is createActivity. When a user logs a call, the service determines the activity type from the input, attaches the contact and deal associations, stamps the current user and timestamp, calculates any deal value changes, and fires a Realtime event for other connected users. All of this was previously scattered across component event handlers.

Service 2: commentsService

Comments are attached to contacts, deals, and activities. The comments service handles threaded conversations with @mention support. The trickiest part of this extraction was the @mention resolution -- parsing mentions from the comment text, resolving them to user IDs, and creating notification records. Previously, this logic lived in a useComments hook that mixed data fetching with UI state management.

What It Owns

Service 3: deletedContactsService

We built a "trash" feature where deleted contacts are soft-deleted and recoverable for 30 days. The deletedContactsService manages the trash can UI, bulk restore operations, and permanent deletion after the retention window. This service was the easiest extraction because it was already somewhat isolated, but the permanent deletion logic had a subtle bug where it would orphan associated activities instead of cascading the delete. The extraction caught and fixed this.

What It Owns

Service 4: changeHistoryService

Every field change on a contact or deal is tracked in a change history table. The changeHistoryService provides the audit trail UI that shows who changed what, when, and what the previous value was. This is critical for sales teams where multiple reps work the same account -- managers need to see who updated the deal value and when.

What It Owns

The recordChange method is called internally by other services, not by components. When activitiesService.createActivity modifies a deal stage, it calls changeHistoryService.recordChange as part of the same operation. This ensures every mutation is tracked without components needing to remember to log changes.

Service 5: emailsDatabaseService

The email sync feature stores a local copy of emails associated with contacts. The emailsDatabaseService manages the local email store -- fetching threads, searching emails, linking emails to contacts, and handling the sync state with Gmail and Outlook. This was the largest extraction by line count because email threading logic is inherently complex.

What It Owns

Service 6: reportService

The analytics feature generates reports: pipeline value by stage, conversion rates, activity counts per rep, revenue forecasts. The reportService runs these queries and caches the results. Reports are expensive to compute (they aggregate across thousands of records), so caching is essential.

What It Owns

Each report method checks a cache keyed by the report type and parameters. If the cache is fresh (under 5 minutes old), it returns cached data immediately. If stale, it runs the query, updates the cache, and returns. The invalidation is event-driven: when a deal stage changes, the pipeline and conversion report caches are invalidated. When an activity is created, the activity report cache is invalidated.

The Extraction Process

We did not extract all six services at once. We extracted one per week over six weeks, shipping each extraction as a standalone PR. The process for each service was:

  1. Identify all call sites -- search the codebase for every Supabase query related to the domain (e.g., every query touching the activities table)
  2. Write the service -- create the service module with all the identified operations, using the existing component code as the implementation
  3. Add tests -- write unit tests for every service method using a mocked Supabase client
  4. Replace call sites -- update each component to call the service instead of Supabase directly. One component at a time, tested after each change.
  5. Delete dead code -- remove the now-unused Supabase imports and inline queries from components
Extract services one at a time, not all at once. Each extraction is a self-contained refactor that can be reviewed, tested, and reverted independently.

Results

Test coverage jumped from 12% to 89% after extracting the service layer, with per-service breakdown.

After all six extractions, the numbers told the story:

The most impactful change was not in any metric. It was in how the team thinks about new features. Before the service layer, the first question was "which component does this go in?" Now the first question is "which service does this belong to?" That shift in thinking has kept the codebase clean as we have grown from 6 services to 14 (and counting). The pattern scales because each new service is independent, testable, and follows the same conventions as the original six.

Try SalesSheet Free

No credit card required. Start selling smarter today.

Start Free Trial