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.
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:
{ data, error }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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.