Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .forceignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**/jsconfig.json
**/.eslintrc.json
**/__tests__/**
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
.DS_Store
target/
temp/
.tmp/
/deploy/*
/debug/
**/dep-dir.txt
Expand Down
78 changes: 69 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FFLib Apex Common Sample
=====================================
![Push Source and Run Apex Tests](https://github.com/apex-enterprise-patterns/fflib-apex-common-samplecode/workflows/Create%20a%20Scratch%20Org,%20Push%20Source%20and%20Run%20Apex%20Tests/badge.svg)

**Dependencies:** Must deploy [Apex Mocks](https://github.com/apex-enterprise-patterns/fflib-apex-mocks) and [Apex Common](https://github.com/apex-enterprise-patterns/fflib-apex-common) before deploying this library
**Dependencies:** Deploy [Apex Mocks](https://github.com/apex-enterprise-patterns/fflib-apex-mocks) and [Apex Common](https://github.com/apex-enterprise-patterns/fflib-apex-common) before deploying this sample.

| Library | Deploy |
|---------|--------|
Expand All @@ -19,23 +19,84 @@ This repository contains a sample application illustrating the Apex Enterprise P

| Platform Feature | Patterns Used |
|------------------|---------------|
| Custom Buttons | Building **UI** logic and calling **Service Layer** code from Controllers |
| Lightning Web Components & Quick Actions | **UI** logic in `@AuraEnabled` controllers calling **Service Layer** code |
| Visualforce (list views) | Bulk **UI** actions via `StandardSetController` pages |
| Batch Apex | Reusing **Service** and **Selector Layer** code from within a Batch context |
| Integration API | Exposing an Integration API via **Service Layer** using Apex and REST |
| Apex Triggers | Factoring your Apex Trigger logic via the **Domain Layer** (wrappers) |
| Integration API | Exposing an Integration API via **Service Layer** using Apex REST |
| Apex Triggers | **Domain Layer** trigger handlers (`fflib_SObjectDomain`) separate from **Domain Layer** service behaviour (`fflib_SObjects`) |
| Invocable Apex & Agentforce | **Service Layer** exposed to Agentforce via invocable actions and an AI authoring bundle |
| Polymorphic invoicing | **Custom Metadata** (`InvoiceTargets__mdt`) drives invoicing across Opportunity, DeveloperWorkItem, and TrainingWorkItem |

Architecture Notes
------------------

This sample uses **concrete** Domain, Selector, and Service classes. `newInstance()` is the default composition: it calls the public constructor with the usual collaborators. Prefer `X.newInstance()` over `new X()` when constructing a service, selector, or domain. Use the constructor to inject mocks in tests or to compose deliberately. Constructors stay public so the [Apex Stub API](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_stub_api.htm) can mock the class (`Test.createStub` cannot stub a type that has only private constructors) and so tests can pass collaborators in.

| Component | Role |
|-----------|------|
| **Services** | `OpportunitiesService`, `InvoicingService`, `AccountsService` — orchestrate selectors, domains, and Unit of Work |
| **Domains** | `Opportunities`, `OpportunityLineItems`, `Accounts` — record behaviour (discounting, invoice DTOs) on `fflib_SObjects`. Constructed via `newInstance(records)` when the records are in hand; not trigger lifecycle |
| **Trigger handlers** | `OpportunitiesTriggerHandler` — `fflib_SObjectDomain` trigger lifecycle (defaults, validation, related updates) |
| **UnitOfWork** | `UnitOfWork.cls` — thin factory; no-arg `newInstance()` uses the sample type list; `newInstance(types)` for a narrower list; tests set `UnitOfWork.mock` |
| **InvoicingTargetsRegistry** | Example of reusing fflib selector/domain factories locally for `InvoicingService.generate` (`InvoiceTargets__mdt`); not an application factory |

Exceptions and local factories
------------------------------

The constructor / `newInstance()` baseline above does not cover every type in that table. These notes are where composition differs, or where a local factory is used instead of an application-wide `Application` class:

- This sample has no application-wide `Application` factory; that is reserved for more advanced DI, such as via [AT4DX](https://github.com/apex-enterprise-patterns/at4dx).
- Controllers, invocable actions, REST resources, and the invoice batch job have no `newInstance()` of their own. The platform calls a static `@AuraEnabled` / `@InvocableMethod` / `@HttpPost`, or a Visualforce `StandardSetController` constructor; the invoice job is constructed with its no-arg constructor (`new CreateInvoicesJob()`). Those constructors compose `OpportunitiesService.newInstance()` (and a selector where needed). Tests inject those collaborators through the constructor.
- A service does not keep one Unit of Work for its lifetime. Each method that commits DML calls `UnitOfWork.newInstance()`, registers its work, and commits; a second call on the same service instance must not reuse a Unit of Work that has already committed. Tests set `UnitOfWork.mock`. `UnitOfWork.newInstance(List<SObjectType>)` is there when a method should not take the whole sample type list.
- A domain wraps records you often do not have until you are already inside a method — for example `Opportunities.applyDiscounts` collects line items and then constructs `OpportunityLineItems`. That domain cannot be passed into the `Opportunities` constructor, so `newInstance(records)` is the factory and tests set the `@TestVisible` `mock` it returns.
- `InvoicingTargetsRegistry` is an example of reusing the fflib selector and domain factories in a more concrete way when one service (`InvoicingService.generate`) needs runtime type resolution. It reads `InvoiceTargets__mdt` and, for a set of source Ids, selects the records and constructs the domain that implements `ISupportInvoicing` (Opportunity, DeveloperWorkItem, TrainingWorkItem, or a further metadata row) so the service does not hard-code those types.

User Mode and CRUD/FLS
----------------------

This sample enforces field-level security (FLS) by using fflib user mode throughout. Selectors pass `fflib_SObjectSelector.DataAccess.USER_MODE` into the `super(...)` constructor for FLS on queries; the UnitOfWork uses `UserModeDML()` via an inner `UserModeUnitOfWorkFactory` in `Application.cls` for FLS on DML. Tests that exercise USER_MODE code use `TestDataFactory` to create a Standard User with the `ApexEnterprisePatternsSampleApp` permission set, then run under `System.runAs(getRunAsUser())`; data setup requiring elevated permissions (e.g. PricebookEntry insert) runs in system context.
This sample targets **API 67.0 and above**, where Apex runs in **user mode by default** at the platform level. That aligns with enforcing CRUD/FLS in production without relying on implicit system-mode behaviour.

When the codebase runs on **API 66 or below** (where system mode is still the platform default), fflib and this sample **still enforce user mode explicitly** through the patterns below — selectors, Unit of Work, and tests do not depend on the platform default alone.

Selectors pass `fflib_SObjectSelector.DataAccess.USER_MODE` into the `super(...)` constructor for FLS on queries. DML runs through `UnitOfWork.newInstance()`, which uses `UserModeDML()`.

Tests that exercise USER_MODE code use `TestDataFactory` to create a Standard User with the `ApexEnterprisePatternsSampleApp` permission set, then run under `System.runAs(getRunAsUser())`. **Setup, DML, queries, and assertions** that touch FLS-protected fields must all run inside that `runAs` block. Data setup requiring elevated permissions (e.g. `PricebookEntry` insert) may run in system context before `runAs`.

| Area | Approach |
|------|----------|
| **Selectors** | `super(false, fflib_SObjectSelector.DataAccess.USER_MODE)` in selector constructors (and `includeFieldSetFields` overload where present) |
| **UnitOfWork** | Inner `UserModeUnitOfWorkFactory` in Application.cls; uses `UserModeDML()` |
| **Tests** | `TestDataFactory`; `@TestSetup` + `System.runAs(getRunAsUser())` for DML/selector tests |
| **UnitOfWork** | `UnitOfWork.cls` factory (`newInstance()` / `newInstance(types)`); uses `UserModeDML()` |
| **Tests** | `TestDataFactory`; `@TestSetup` + `System.runAs(getRunAsUser())` for USER_MODE tests |
| **Permission set** | `ApexEnterprisePatternsSampleApp` grants field-level access for USER_MODE tests |

Local Development
-----------------

**API version:** 67.0 and above (`sfdx-project.json`). API 67 introduced user mode as the Apex default; this sample is written for that baseline and remains compatible with later API versions.

**Scratch org:** `config/project-scratch-def.json` enables Agentforce/Einstein (`Einstein1AIPlatform` feature) for deploying the AI authoring bundle and invocable actions. Your Dev Hub must support these features.

Deploy in order:

```bash
# 1. Create scratch org
sf org create scratch --definition-file config/project-scratch-def.json --alias fflib-sample-scratch --set-default

# 2. Deploy dependencies
git clone https://github.com/apex-enterprise-patterns/fflib-apex-mocks.git temp/fflib-apex-mocks
git clone https://github.com/apex-enterprise-patterns/fflib-apex-common.git temp/fflib-apex-common
sf project deploy start --source-dir temp/fflib-apex-mocks --target-org fflib-sample-scratch
sf project deploy start --source-dir temp/fflib-apex-common --target-org fflib-sample-scratch

# 3. Deploy this sample
sf project deploy start --target-org fflib-sample-scratch

# 4. Run tests
sf apex run test --target-org fflib-sample-scratch --wait 10
```

Assign `ApexEnterprisePatternsSampleApp` to users who need access to the sample app's custom fields and tabs. Tests assign this permission set automatically via `TestDataFactory`; manual assignment is not required to run Apex tests.

Application Enterprise Patterns on Salesforce Lightning Platform
================================================================

Expand All @@ -44,9 +105,8 @@ Design patterns are an invaluable tool for developers and architects looking to
More Information on Trailhead
--------------------------------------------

There are two Trailhead Modules for Apex Enterprise Patterns:
Trailhead modules for Apex Enterprise Patterns:

- [Apex Enterprise Patterns - Separation of Concerns](https://trailhead.salesforce.com/en/content/learn/modules/apex_patterns_sl/apex_patterns_sl_soc)
- [Apex Enterprise Patterns - Service Layer](https://trailhead.salesforce.com/en/content/learn/modules/apex_patterns_sl)
- [Apex Enterprise Patterns - Domain and Selector Layer](https://trailhead.salesforce.com/en/content/learn/modules/apex_patterns_dsl)

10 changes: 10 additions & 0 deletions config/project-scratch-def.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
{
"orgName": "apex-common-samplecode",
"edition": "Developer",
"features": ["EnableSetPasswordInApi", "Einstein1AIPlatform"],
"settings": {
"lightningExperienceSettings": {
"enableS1DesktopEnabled": true
},
"mobileSettings": {
"enableS1EncryptedStoragePref2": false
},
"agentPlatformSettings": {
"enableAgentPlatform": true
},
"einsteinGptSettings": {
"enableEinsteinGptPlatform": true
}
}
}
2 changes: 1 addition & 1 deletion sfdx-project.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
],
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "63.0"
"sourceApiVersion": "67.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# OpportunityOperations - Employee agent for opportunity discounts and invoices
# Calls the ApplyDiscount and CreateInvoice invocable Apex actions

config:
developer_name: "OpportunityOperations"
agent_label: "Opportunity Operations"
agent_type: "AgentforceEmployeeAgent"
description: "Helps employees apply opportunity discounts and create invoices from opportunities."

variables:
currentRecordId: mutable string = ""
description: "Id of the record the employee opened the agent from"
visibility: "External"
opportunity_id: mutable string = ""
description: "Salesforce Opportunity Id for the current request"
discount_percentage: mutable number = 0
description: "Discount percent to apply, for example 10 for 10 percent"
invoice_id: mutable string = ""
description: "Id of the Invoice__c created from an Opportunity"
invoice_name: mutable string = ""
description: "Auto-number Name of the Invoice__c created from an Opportunity"

system:
messages:
welcome: "I can apply discounts to opportunities and create invoices from them. What would you like to do?"
error: "I could not complete that opportunity request. Please try again."
instructions: "You help sales employees discount opportunities and create invoices. Use only the provided Apex actions. Never invent an Opportunity Id. If the user names an Opportunity Id, use that. Otherwise use {!@variables.currentRecordId} when it is set. Ask for any missing Id or discount percent before calling an action."

language:
default_locale: "en_US"

start_agent agent_router:
description: "Welcome the user and route discount or invoice requests"

reasoning:
instructions:|
Select the tool that best matches the user's message and conversation history. If it's unclear, make your best guess.
actions:
go_to_apply_discount: @utils.transition to @subagent.apply_discount
description: "User wants to apply a discount to an Opportunity"
go_to_create_invoice: @utils.transition to @subagent.create_invoice
description: "User wants to create an Invoice from an Opportunity"

subagent apply_discount:
description: "Applies a percentage discount to an Opportunity through ApplyDiscount Apex"

actions:
apply_opportunity_discount:
description: "Applies a percentage discount to an Opportunity and its discountable products"
require_user_confirmation: False
inputs:
opportunityId: object
description: "Id of the Opportunity to discount"
is_required: True
complex_data_type_name: "lightning__recordIdType"
discountPercentage: number
description: "Discount percent to apply, for example 10 for 10 percent"
is_required: True
outputs:
opportunityId: object
description: "Id of the Opportunity that was discounted"
complex_data_type_name: "lightning__recordIdType"
discountPercentage: number
description: "Discount percent that was applied"
target: "apex://ApplyDiscount"

reasoning:
instructions:->
| Apply a discount to an Opportunity using {!@actions.apply_opportunity_discount}.
You need an Opportunity Id and a discount percent.
Prefer an Opportunity Id the user named.
Otherwise use {!@variables.currentRecordId} when it is set.
If either the Opportunity Id or discount percent is missing, ask for it.
After the action succeeds, confirm the Opportunity Id and the percent that was applied.
if @variables.currentRecordId:
| Record page Opportunity Id: {!@variables.currentRecordId}
if @variables.opportunity_id:
| Current Opportunity Id: {!@variables.opportunity_id}
if @variables.discount_percentage > 0:
| Current discount percent: {!@variables.discount_percentage}
actions:
apply_opportunity_discount: @actions.apply_opportunity_discount
with opportunityId=...
with discountPercentage=...
set @variables.opportunity_id = @outputs.opportunityId
set @variables.discount_percentage = @outputs.discountPercentage

subagent create_invoice:
description: "Creates an Invoice__c from an Opportunity through CreateInvoice Apex"

actions:
create_opportunity_invoice:
description: "Creates an Invoice from an Opportunity, optionally applying a discount first"
require_user_confirmation: False
inputs:
opportunityId: object
description: "Id of the Opportunity to invoice"
is_required: True
complex_data_type_name: "lightning__recordIdType"
discountPercentage: number
description: "Optional discount percent applied before invoicing, for example 10 for 10 percent"
is_required: False
outputs:
invoiceId: object
description: "Id of the Invoice__c that was created"
complex_data_type_name: "lightning__recordIdType"
invoiceName: string
description: "Invoice Number (Name) of the Invoice__c that was created, for example INV-00000001"
target: "apex://CreateInvoice"

reasoning:
instructions:->
| Create an invoice from an Opportunity using {!@actions.create_opportunity_invoice}.
You need an Opportunity Id.
Prefer an Opportunity Id the user named.
Otherwise use {!@variables.currentRecordId} when it is set.
If no Opportunity Id is available, ask for it.
Only include a discount percent when the user asked for one.
After the action succeeds, tell the user the new Invoice Number (invoiceName), not the Salesforce Id.
if @variables.currentRecordId:
| Record page Opportunity Id: {!@variables.currentRecordId}
if @variables.opportunity_id:
| Current Opportunity Id: {!@variables.opportunity_id}
if @variables.invoice_name:
| Last Invoice Number: {!@variables.invoice_name}
actions:
create_opportunity_invoice: @actions.create_opportunity_invoice
with opportunityId=...
with discountPercentage=...
set @variables.invoice_id = @outputs.invoiceId
set @variables.invoice_name = @outputs.invoiceName
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<AiAuthoringBundle xmlns="http://soap.sforce.com/2006/04/metadata">
<bundleType>AGENT</bundleType>
<versionTag>v0.1</versionTag>
</AiAuthoringBundle>
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<CustomApplication xmlns="http://soap.sforce.com/2006/04/metadata">
<defaultLandingTab>standard-home</defaultLandingTab>
<brand>
<headerColor>#014486</headerColor>
<shouldOverrideOrgTheme>false</shouldOverrideOrgTheme>
</brand>
<description>Sample application illustrating Apex Enterprise Paterns, https://github.com/financialforcedev/fflib-apex-common</description>
<formFactors>Small</formFactors>
<formFactors>Large</formFactors>
<isNavAutoTempTabsDisabled>false</isNavAutoTempTabsDisabled>
<isNavPersonalizationDisabled>false</isNavPersonalizationDisabled>
<isNavTabPersistenceDisabled>false</isNavTabPersistenceDisabled>
<label>Apex Enterprise Patterns</label>
<navType>Standard</navType>
<tabs>standard-home</tabs>
<tabs>standard-Account</tabs>
<tabs>standard-Opportunity</tabs>
<tabs>standard-Product2</tabs>
<tabs>Invoice__c</tabs>
<tabs>WorkOrder__c</tabs>
<uiType>Lightning</uiType>
</CustomApplication>
Loading
Loading