Domain Layer - wimvelzeboer/fflib-apex-extensions GitHub Wiki
The domain is one of the three core layers of the Separation of Concern design pattern.
It is a wrapper around a list of objects and it contains both data and behavior.
The only concern/context known to the behavior is the List of objects the domain encapsulates. It is not aware of anything outside the domain, so there are no references to other classes or object types besides the classes it is extended from.
The behavior part of the domain only has four types of methods;
A graphical overview
+--- Domain ------+
| +-------------+ |
| | List< ... > | |
| +-------------+ |
| |
| Getters ----- | ----- > Set<...> / List<...> / Map<..., ...>
| |
| Setters <---- | ------ data/value in the most primitive form
| |
| Selectors ----- |---------------- > +--- Domain ------+
+-----------------+ | +-------------+ |
| | List< ... > | |
| +-------------+ |
| |
| Getters |
| Setters |
| Selectors |
+-----------------+
-
The getter, setter and seletor methods always targets (iterate over) all the records the domain contains.
-
The domain is not aware of other (S)ObjectType than its own,<br/> Even when dealing with relationships, to the domain the relationship is just a field with an Id.
-
It is not aware of other classes, with the exception for a constant class of the same (S)ObjectType as the domain.
A domain class name is written in the plural form of the (S)ObjectType,
e.g. 'Accounts' for the SObjectType Scheme.Account
,
or 'CustomerDetails' for the data-class CustomerDetail
Interface
public interface IAccounts extends fflib_ISObjects
{
// all method signatures go here
}
The interface is extended from fflib_ISObjects
and inherits the three core methods getRecords
, getRecordIds
and getSObjectType
.
Implementation
public with sharing class Accounts
extends SObjects
implements IAccounts
{
// Class constructor
@TestVisible
private Accounts(List<SObject> records)
{
super(records, Schema.Account.SObjectType);
}
// Method to create new domain instances via record Ids
public static IAccounts newInstance(Set<Id> ids)
{
return (IAccounts) Application.Domain.newInstance(ids);
}
// Method to create new domain instances via records
public static IAccounts newInstance(List<SObject> records)
{
return (IAccounts) Application.Domain.newInstance(
records,
Schema.Account.sObjectType
);
}
// Getter Methods
// Selector Methods
// Setter Methods
// Sub-class constructor
public class Constructor implements fflib_IDomainConstructor
{
public fflib_IDomain construct(List<Object> objectList)
{
return (fflib_IDomain) new Accounts((List<SObject>) objectList);
}
}
}
The implementation class is extended from SObjects
as this domain contains a SObjectType, if it were to contain a data-class it should be extended from Objects
.
This class contains three constructor types. One class constructor method accepting a list of SObjects, two methods to create new instances, and a sub-class named Constructor to permit the dynamic creation of the Domain class by the Application class.
Notice that the class constructor method is private, to prevent direct instantiation from outside the domain itself with the exception for unit tests. Having this constructor set to private will force the use of the Application class to create domain instances. This is to make sure that when you register a mock of a domain that it will always be used.
The two static methods newInstance
are the main points to create a new instance of the domain class implementation.
Usage
IAccounts domain = Accounts.newInstance(Ids);
// or
IAccounts domain = Accounts.newInstance(recordList);
The getter methods retrieve information from the list of object the domain contains. They return data in their most basic form with primitive data types, in Sets, Lists or Maps
Getter name signature | Description | Example |
---|---|---|
get FieldName |
Get all the values from one particular field. |
List<String> getNames() |
get FieldName By FieldName |
Get a map with the values of the given first field name grouped by the values of the second field name |
Map<Id, String> getNameById() |
getBy FieldName |
Get a map of domains grouped by the given field value |
Map<String, Accounts> getByShippingCountry() |
getRecords |
Get a list of all the records of the domain |
// for all records // for a subset of records (used by filter methods) |
Take a good look at the naming convention as this is very important if you want to increase code reuse.
Not adhering to this naming convention might result in having multiple methods doing the same thing.
Imagine you have the following methods; fetchAccountName
, getAccountName
, getName
all doing the same thing.
Too bad and a big waste of your time, so make sure you get it right!
The selector methods filter the list of objects contained in the domain based on certain criteria.
It returns a new instance of a domain containing the objects meeting the criteria.
Try to keep it simple and only have one condition per selector method, and chain them if you need more complexity.
List<Account> records =
AccountsSelector.newInstance(accountRecords)
.selectByShippingCountry('Holland')
.selectByRating('Hot')
.selectByNumberOfEmployeesGreaterThan(50);
Filter name signature | Description | Example |
---|---|---|
selectBy FieldName ( value ) |
Create a domain with a subset of records where the given field name had the provided value |
Accounts selectByShippingCountry(String countryName); |
selectBy FieldName Condition ( value ) |
Create a domain with a subset of records where the value of the field meets the criteria condition of the provided value. |
Accounts selectByNumberOfEmployeesGreaterThan(Integer numberOfEmployees); |
selectWith FieldName |
Get a domain with values for the given field name |
Accounts selectWithShippingCountry() |
selectWithBlank FieldName |
Get a domain with blank values for a given field name |
Accounts selectWithBlankShippingCountry(); |
selectWithNonBlank FieldName |
Get a domain with non-blank values for a given field name |
Accounts selectWithNonBlankShippingCountry(); |
Setter methods change data in the (S)Objects on the list the domain contains. These methods accept data or values in the most primitive form.
It is important to remember the first rule, to always target all the records in the domain. So, these setter methods changes the data an all objects contained by the domain. If data only needs to be modified on just a sub-set of (S)Objects, then you need to chain the setter method onto a selector method.
List<Account> records =
AccountsSelector.newInstance(accountRecords)
.selectByShippingCountry('Holland')
.selectByNumberOfEmployeesGreaterThan(50)
.setRating('Hot');
.
Setter name signature | Description | Example |
---|---|---|
set FieldName ( value ) |
Change all the values of the given field into the provided value |
Accounts setShippingCountry(String countryName) |
set FieldName By FieldName (Map<Id, String> values) |
Change the values of the first field name into the provided value which is grouped by the second field name |
Accounts setMailingCountryByAccountId(Map<Id, String> countryNameByAccountId) |
Domains for objects with many fields can become very large. One way of addressing this is to create separate classes for each concern inside the domain.
-
Selectors
-
Accessors
or
-
Selectors
-
Getters
-
Setters
public abstract class AccountGetters extends fflib_sObjects { ... }
public abstract class AccountSetters extends AccountsGetters { ... }
public abstract class AccountSelectors extends AccountsSetters { ... }
public class Accounts extends AccountSelectors implements IAccounts { ... }
The domain interface would look like:
public interface IAccounts extends fflib_ISObjects, IAccountGetters, IAccountSetters, IAccountSelectors
{
...
}
When working with multiple implementations, we still use the same structure and add a alternative implementation.
In the following example we have a domain for the object Parcel
. A parcel contains, among many other things, a track-and-trace code.
public class Parcel
{
String trackAndTraceCode;
...
public String getTrackAndTraceCode()
{
return this.trackAndTraceCode;
}
...
}
The domain has an Interface, defining the method signatures for all domain implementations of the object Parcel
.
public Interface IParcels extends fflib_IObjects
{
IParcels selectWithValidTrackAndTraceCode();
}
There is one main implementation for Parcels domain, containing the default constructors and in this example a filter method to only return a domain with parcels with a valid track-and-trace code.
In this implementation the validation is quite simple as it looks only for a non-blank track-and-trace code.
public class Parcels extends fflib_Objects implements IParcels
{
...
public IParcels selectWithValidTrackAndTraceCode()
{
List<Parcel> result = new List<Parcel>();
for (Parcel object : (List<Parcel>) getObjects())
{
if (String.isBlank(object.getTrackAndTraceCode()) continue;
result.add(object);
}
return object;
}
...
}
One day management decides that they want to be able to easily switch between DHL and TNT, just depending on the best contract they can get. Both parcel service companies use a different format for their track-and-trace code, that should be reflected in the implementation.
public class DHLParcels extends fflib_Objects implements IParcels
{
...
public IParcels selectWithValidTrackAndTraceCode()
{
List<Parcel> result = new List<Parcel>();
for (Parcel object : (List<Parcel>) getObjects())
{
String trackingCode = object.getTrackAndTraceCode();
if (String.isBlank(trackingCode) || trackingCode.length() != 10) continue;
result.add(object);
}
return object;
}
...
}
Notice the change in the condition, the tracking code is now only valid when it has a length of 10 characters.
TNT has a more complex tracking code. The validation is also extracted into its own methods, to simplify the condition of the if statement.
public class TNTParcels extends fflib_Objects implements IParcels
{
...
public IParcels selectWithValidTrackAndTraceCode()
{
List<Parcel> result = new List<Parcel>();
for (Parcel object : (List<Parcel>) getObjects())
{
String trackingCode = object.getTrackAndTraceCode();
if (validTrackingCode(trackingCode) == false) continue;
result.add(object);
}
return object;
}
private static validTrackingCode(String trackingCode)
{
return String.isNotBlank(trackingCode)
&& trackingCode.startWith('GE')
&& trackingCode.endsWith('WW')
&& trackingCode.length() == 13;
}
...
}
The Application
class can be used to easily manage which implementation is used.
Force-Di can also be utilized here to dynamically switch implementations without making code-changes.
A unit test for a domain class is one of the easiest to create, as it doesn’t involve any database interaction or mocking. It is only using data created in memory, and therefore these unit-test are the quickest to run.
In most cases we don’t even need valid records, like records with an Id. We just only require the fields that we are using in the method that we want to test. Like in the following example we want to write a test for a domain method that is interacting only with the ShippingCountry field on Accounts.
Account record = new Account(ShippingCountry = 'USA'); // this is enough of mimic an existing record.
When testing with relationships we typically do not even need the related record, only an Id.
Contact record =
new Contact(
AccountId = fflib_IDGenerator.generate(Account.SObjectType),
MailingCountry = 'USA'
);
Notice that the mandatory field LastName
is missing.
The following example contains a unit test for a getter method that returns the values of a field. Notice that only the ShippingCountry field is provided, we do not need anything else.
@IsTest
private class AccountsTest
{
@IsTest
static void itShouldGetTheShippingCountryValues()
{
// GIVEN a domain with accounts containing ShippingCountry values
AccountsImp domain = new Accounts(
new List<Account>
{
new Account(ShippingCountry = 'USA'),
new Account(ShippingCountry = 'Ireland'),
new Account(ShippingCountry = 'Holland')
});
// WHEN we get the ShippingCountry values for the domain
System.Test.startTest();
Set<String> result = domain.getShippingCountries();
System.Test.stopTest();
// THEN the values should be returned
System.assert(
result.containsAll(
new Set<String> { 'USA', 'Ireland', 'Holland' }
)
);
}
}