5 Anti-Patterns In Package Dependency Design and How to Avoid Them

Salesforce Architects
Salesforce Architects
11 min readMar 18, 2021

--

3d illustration of colored blocks

A package is a group of metadata, isolated and organized to be easier to upgrade and maintain. In the org-based development model, the metadata that belongs to an app is spread across the Salesforce organization. This interdependency can make an organization, and the apps within that organization, difficult to maintain. Alternatively, in the package-based development model, the metadata of an app or library belongs in a package container — or even a series of package containers. This separation and organization enables greater reuse of metadata and makes upgrades to Salesforce apps faster and more predictable.

However, adopting a package-based delivery model isn’t as simple as organizing metadata into a single package. You’ll be more likely to have multiple packages, containing interrelated components. Package dependencies are created when a component inside a specific package references components or configurations outside of that package. Dependencies can be formed not only in Apex classes and triggers, but also in Actions, Lightning Pages, and Flows. In this post, we focus on ways to handle dependencies between unlocked packages.

There are two general approaches to building unlocked packages with complex dependencies: building org-dependent unlocked packages (which are tightly coupled to an installing organization) or building loosely coupled unlocked packages and designing to manage dependencies. Here we talk about the second option, and walk through ways to design and build loosely coupled unlocked packages using the dependency injection design pattern, a very useful technique for avoiding liabilities associated with package dependency.

The Five Anti-Patterns of Dependency Design and How to Avoid Them

One of the main objectives of packaging is to make deployment easier, more standardized, and repeatable. Managing and decoupling dependencies between metadata are essential elements in accomplishing this objective.

In this section, we describe the common traps that derail architects from the intended vision. More importantly, we discuss how to apply Apex Enterprise Patterns and dependency injection techniques to solve those issues.

Anti-pattern 1: Using packages to create silos (and technical debt)

Description: An unlocked package can be a useful tool to enable different product owners to manage the design and development of separate apps in a Salesforce organization. But it is inappropriate to treat each package as if it is a totally independent territory, and to allow teams to develop and design packages that don’t take into consideration how the package will interact with other apps and shared resources in the same Salesforce org.

Example: For example, the best practice for designing an automation process is “one automation tool per object”. Often we find different application teams make their own choice of automation tool based upon their own scope of requirements. The end result is mixed and potentially conflicting automation on key objects such as Accounts, Contacts, Cases, or Opportunities. This kind of technical debt can introduce significant performance issue and even runtime errors.

How to avoid this: The trigger handler framework is an excellent way to enforce the rule of “one automation tool per object.” But what happens when you have multiple packages that need to introduce automation logic to a common object, say, the Account object? One solution is to use dependency injection in the code for your trigger handlers. With this approach, your trigger handlers simply call an injector service to load an appropriate automation configuration. At run time, custom metadata or other runtime configuration data passed to the injector service controls how automation logic contained in different packages gets invoked. This approach can also be extended to Flows, as you can also use Apex to invoke a flow.

A package strategy for the trigger injection pattern can be as straightforward as extending a base Triggerinjector class . (For implementation details, see the force-di-trigger-demo example.)

Diagram of the trigger injection pattern and related packages.

Anti-pattern 2: Designing monoliths instead of easy-to-use services

Description: Separation of concerns is an important design principle adopted by many architects. Unlocked packages can be a great way to introduce reusable services and greater efficiency into the design of your Salesforce organization and your code base. However, services that are too rigid or difficult to extend can lead to technical debt for future dependent packages. Teams might even begin to try to bypass poorly designed services and build redundant logic into their packages — completely undermining any proper separation of concerns.

Example: Let’s say you have a service in a base package that reads Account data. A SOQL statement in the service has a predefined list of fields based upon the data model at the time when the package was created. When additional fields are added to the Account object by a different package, the shared service must also be updated to reference the new fields — or, if teams can’t update the base package, the new package has to create its own service to read Account data.

How to avoid this: Don’t implement services with hard-coded logic or data model information. The scenario above can easily be handled by applying the concept of dynamic field injection in the Apex Enterprise Pattern — Selector Layer implementation. Instead of hard-coding the list of fields to query, the Selector class should construct the field list by using custom metadata or runtime configuration data that can be set in separated packages.

The Easy Spaces Sample App provides a good example of polymorphic service implementation using custom metadata type in a selector pattern. (For details, refer to classes and metadata defined in ESBaseCodeLWC package.)

Diagram of the Easy Spaces unlocked packages and dependencies.

Anti-pattern 3: Writing code to get the implementation done instead of implementing the right code

Description: No matter how well you design an app, over time it must grow and change or it will die. How can we build our app packages so that when we need to change them, we can do so with the least possible impact on the existing code? One way to ensure you won’t be able to make changes and extend package logic gracefully is to write code that is tightly coupled to current implementation details or requirements. This is very similar to anti-pattern 2, in that it involves code that is rigid and hard to extend.

Example: Let’s imagine an abstract class, Logger, with two concrete implementations: a custom Logger object and platform-event-based logging. Writing code specific to a particular implementation would be similar to the snippet below:

SysLog_Event__e eventlog = new SysLog_Event__e();
eventlog.payload__c = 'some message';
EventBus.publish(eventlog);

Declaring the variable eventlog as type SysLog_Event__e, is a concrete implementation of the logging capability, and tightly couples the code to this specific implementation. This introduces a dependency between the package containing this code and the SysLog_Event__e platform event. Any changes to either of the concrete implementations of our logger functionality might require rework in the Logger class and force additional package updates.

How to avoid this: Luckily, there are design principles for this type of situation:

  1. Identify the aspects of your app that vary and separate them from those that stay the same
  2. Program to an abstraction, not an implementation

In the above example, the caller of the SysLog_Event__e object controls its creation and life time. The control can be inverted by delegating the object creation to another class. In other words, invert the dependency creation control from the caller to another class, as shown below. Rather than hard-cording the instantiation of the dependency, it is isolated and assigned at runtime. This implementation exploits polymorphism by programming to an abstraction so that the actual runtime object is not locked into the package.

Logger logger = LoggerFactory.newInstance(EventLogger.class);
logger.log('some message');

In practice, these design principles can be applied in the solution to break the tight coupling between a package and the Apex classes in your organization that the package might depend on to perform some operation. The solution uses an injector class to dynamically load and return our implementation classes at runtime by inverting different kinds of controls to achieve loose coupling. The injector does not have a compile-time reference to any particular implementation class thus, removing all implementation class references from the package.

To demonstrate how to use inversion of control (IoC) to decouple packages from implementations, let’s expand on the logging scenario. In this diagram, concrete classes for the Logger interface are defined in custom metadata and dynamically loaded by the service injector in package-di. A stub implementation of the interface contract in package-a uses an injector to load the concrete implementation at runtime. Apex stub is an abstraction of a real-world entity and enables loose coupling between the entity and dependencies. package-b consists of a client service and custom metadata record definition specifying the Logger concrete implementation class.

Diagram of inversion of control pattern.

The IoC principle helps in designing loosely coupled packages that are more testable, maintainable, and extensible.

Anti-pattern 4: Shortchanging unit testing across package boundaries

Description: Imagine you’re testing code that relies on another object’s internal state or implementation from another package. To get your test method to work, you could just make calls from the method to the controlling package or its dependencies, but there are a couple of problems with this approach. The first problem is the dependencies may not yet be finalized or exist. Secondly, since the test method is tightly coupled to its dependencies, changes to the latter will require (at the minimum) the rerun of all the dependents or (worst-case scenario) an update to the dependent unit test methods and packages. Neither of these is an ideal solution.

Example: This scenario can occur when a test method references an existing class within the organization or from another package. This can force a developer to list additional dependencies in the unlocked package definition, just to get test methods to compile.

How to avoid this: Keep tests easy to run by isolating tests from dependencies in the controlling package as much as possible. To isolate the dependencies, you can create an interface for these calls to enable you to have multiple implementations. You can have one implementation for development that makes it easy to run tests, and another one for production that uses the real object.

There are two basic types of objects you can use to simulate the functionality of a production class: a Stub or a Mock object. Create mock objects to isolate the code you are testing from any other code in the organization that you are not testing, for example, dependencies or code from other sources other than the one actively under test. To learn more about this approach, see the Use Mocks and Stub Objects Trailhead module.

Anti-pattern 5: Creating tightly coupled packages through event handling

Description: It’s fairly common for one package to need to be notified of an event occurring in another package. This kind of communication is often implemented by some kind of API call between packages. The downside of this approach is that it can create tightly coupled packages that have to know about specific APIs exposed to each other. If you change a service parameter or implementation in one package, you may have cascading changes in calling packages.

Example: Let’s assume the following operations are triggered when a Lead is converted by Package A in a multi-package organization:

  1. Call a service in package B to update an existing Account
  2. Call an email service in package C to notify the Sales Manager
  3. Call a service in another package to do something else
Diagram of lead conversion logic crossing package boundaries.

These service calls can be orchestrated in different ways, but in general an API-based approach leads to tightly coupled packages and integration that is harder to maintain.

How to avoid this: Using a dependency injection (DI) based event subscription pattern enables communication between packages while keeping them loosely coupled as illustrated in the following diagram. Packages B and C subscribe to a Convert Lead event by adding entries in the event subscription custom metadata, including which class within the package should be called to handle the event. When a Convert Lead event occurs, Package A queries the event subscription metadata records to find out the packages that have subscribed to this event, and then orchestrates class invocations based on the custom metadata entries. With this approach, event handler classes and packages are not tightly coupled.

Diagram of loosely coupled unlocked packages

Using this DI-based approach generally leads to more versatile and easier-to-maintain integrations between packages.

The above solution is comprised of the following artifacts:

  • Core library/package:
  • Interface: IEventConsumer
  • Custom Metadata Type: EventConsumerSubscription__mdt
  • Apex Class: EventOrchestrator for querying event subscription metadata records and orchestrating the execution of implementation classes
  • Packages B and C:
  • Apex Class: EventConsumer implements IEventsConsumer for handling an event
  • Custom Metadata Record defines the implementation class for handling an event

Conclusion

Adopting more modern app development strategies, including unlocked packages, is not an all-or-nothing proposition. It is common for organizations to have a combination of packaged and unpackaged metadata. Building loosely coupled packages can help your teams release changes predictably and with greater flexibility. If you’re just getting started with unlocked packages, it’s often easier to build a first package based off of something that is well-defined and self-contained. Whether you plan to convert a library of reusable Apex code to package or build a net new app as a package, use the tips in this post to avoid common pitfalls that incur technical debt.

Resources

About the Authors

Abi Ashiru works as a Technical Architect at Salesforce and helps businesses innovate and grow with technology. He has worked extensively on building scalable solutions across the Salesforce clouds. When he is not building solutions at Salesforce, he enjoys exploring fourth industrial revolution technologies.

Ivan Yeung works as a Success Architect at Salesforce. He helps innovative customers architect, build, and manage enterprise-scale applications on the Salesforce Platform. He is passionate about applying leading edge technology, such as NLP AI and blockchain, in Salesforce solutions.

--

--

Salesforce Architects
Salesforce Architects

We exist to empower, inspire and connect the best folks around: Salesforce Architects.