Write Clean, Maintainable Code by Abstracting Your Logic
Building lightning web components becomes complex as your salesforce org grows. We notice in most cases that we are using the same apex callout multiple times in different components or even copy and pasting the same functions like showing simple toast messages. This repetition makes code hard to test and maintain. The solution to this problem is to adapt a modular architecture by building a service layer module.
In Lightning web components, a service layer isn’t just a physical folder or a specific Salesforce feature. Instead, it is a design pattern where you write reusable logic into separate JavaScript modules . These modules can be used as libraries that your UI components can import and use. In this way we can clean our components and focus mainly on presentation and user interaction, while the service modules handle the heavy lifting of data retrieval, business logic, and external communication.
Learn Design Patterns and Best Practices to build reusable Lightning Web Components.
Why You Need a Service Layer
The main reason for using service layers is decoupling. We know that our UI component doesn’t care about where data is being fetched or where the utility function is coming from. It only cares about displaying the output and capturing the input from the user.
For example if you want to change the endpoint of an external api we dont need to change it everywhere we have used it we just need to change it one service module, which makes your codebase more resilient to change and significantly easier to debug .
Building a Utility Service: The Logger
Let’s start with a simple but powerful example: a logging service. Instead of using console.log scattered throughout your components, you can create a service that standardizes how you log messages. This gives you a central place to control logging verbosity or even send logs to a custom object in the future.
Create a new folder in your LWC directory called logger. Inside, create a logger.js file. Notice that this bundle does not have an HTML file because it is not a visual component.
// logger/logger.js
const isDebugMode = true; // This could be dynamically set from a Custom Label or Setting
const log = (message, level = 'INFO') => {
if (isDebugMode) {
// eslint-disable-next-line no-console
console.log(`[${new Date().toISOString()}] [${level}]: ${message}`);
}
};
const info = (message) => log(message, 'INFO');
const warn = (message) => log(message, 'WARN');
const error = (message) => log(message, 'ERROR');
export { info, warn, error };
Now, any component can import these functions. To use it, you would navigate to another component, like a housingMap component, and import the specific functions you need.
// housingMap/housingMap.js
import { LightningElement } from 'lwc';
// Import the service
import { info, error } from 'c/logger';
export default class HousingMap extends LightningElement {
connectedCallback() {
info('housingMap component has been initialized.');
}
handleLocationError(err) {
error(`Failed to get user location: ${err.message}`);
}
}
Creating a Data Service for Apex Calls
Another most used scenario was making simple Apex calls. For using an apex method in your component you need to import that method into your component using @salesforce/apex. It works but it couples your apex code and component tightly. Using a data service module will act as a middleman here.
Imagine you have an Apex class that retrieves property listings. Instead of calling it directly in your component, you create a service.
First, let’s assume you have a simple Apex method:
// Apex Class: PropertyController
public with sharing class PropertyController {
@AuraEnabled(cacheable=true)
public static List<Property__c> getPropertiesByCity(String city) {
// Logic to fetch properties
return [SELECT Id, Name, Property_Value__c, Property_name__c FROM Property__c WHERE Location__c = :city WITH SECURITY_ENFORCED];
}
}
Now, create your data service module. Create an LWC bundle called propertyService. Inside, create a propertyService.js file.
// propertyService/propertyService.js
import getPropertiesByCity from '@salesforce/apex/PropertyController.getPropertiesByCity';
// You could add caching, error transformation, or combine multiple calls here
const fetchProperties = (city) => {
return getPropertiesByCity({ city: city })
.then(result => {
// You can transform the data here if needed
return result.map(property => ({
...property,
Label: `${property.Property_name__c} - ${property.Property_Value__c}`
}));
})
.catch(err => {
// Standardize error handling
console.error('Error fetching properties from service', err);
throw err; // Re-throw so the component can handle UI feedback
});
};
export { fetchProperties };
Your UI component, such as a propertyList component, becomes much cleaner and remains ignorant of the Apex implementation.
// propertyList/propertyList.js
import { LightningElement, track } from 'lwc';
import { fetchProperties } from 'c/propertyService';
export default class PropertyList extends LightningElement {
@track properties;
@track error;
connectedCallback() {
this.loadProperties('San Francisco');
}
loadProperties(city) {
fetchProperties(city)
.then(data => {
this.properties = data;
this.error = undefined;
})
.catch(err => {
this.error = 'We could not load the properties. Please try again.';
this.properties = undefined;
});
}
}
Advanced Pattern: Class-Based Services
For making complex integrations to external systems using REST API, it was better to use class based service. This will allow you to store data like API Key or endpoint URL with server instance.
Let us create a service for a geographic location API. Here we create a service module which export a class. The parameters were passed to the constructor, and the methods handle the specific API calls.
// geoService/geoService.js
export default class GeoService {
apiKey;
endpoint;
constructor(apiKey, endpoint) {
this.apiKey = apiKey;
this.endpoint = endpoint;
}
// Method to get coordinates for an address
getCoordinates(address) {
// Example using fetch API (simplified)
const url = `${this.endpoint}/geocode?address=${encodeURIComponent(address)}&key=${this.apiKey}`;
return fetch(url)
.then(response => response.json())
.then(data => {
// Transform the external API response into a standard format
return {
latitude: data.results[0].geometry.lat,
longitude: data.results[0].geometry.lng
};
});
}
}
A component using this service would first need to import it and then instantiate it, likely using data from Custom Metadata or a Custom Setting to provide the API key and endpoint.
// housingMap/housingMap.js
import { LightningElement } from 'lwc';
import GeoService from 'c/geoService';
// Assume you have a method to get config values
import getGeoConfig from '@salesforce/apex/ConfigController.getGeoConfig';
export default class HousingMap extends LightningElement {
geoService;
connectedCallback() {
getGeoConfig().then(config => {
this.geoService = new GeoService(config.apiKey, config.endpoint);
});
}
handleFindOnMap(event) {
const address = event.detail.address;
this.geoService.getCoordinates(address)
.then(coords => {
// Dispatch an event or call a method to center the map
this.template.querySelector('c-map-view').centerMap(coords.latitude, coords.longitude);
})
.catch(err => {
// Handle error
});
}
}
Conclusion
We should keep a few principles in mind while building these modules. First thing is to keep them stateless whenever possible. If your service maintains an internal state, be mindful of how that state is shared across different components. The second thing is to handle errors inside the service gracefully. We should transform cryptic server errors into user-friendly messages or consistent error objects that your components can easily display . Finally, do not over-modularize . And if a function or method is going to be used by only one component and most likely won’t be reused it is better to use it within that component until there is a genuine need for reuse.
Thanks for reading and happy coding 🙂







