Part 69: Unlocked Packages in Salesforce
Welcome back to the Salesforce series. In Part 67, we explored the Salesforce CLI and learned how to interact with orgs, deploy metadata, and run commands from the terminal. In Part 68, we dove into Scratch Orgs and saw how they provide disposable, fully configurable development environments. This post ties those two threads together by introducing Unlocked Packages — the packaging model that Salesforce recommends for organizing, versioning, and deploying metadata in a modern development workflow.
If you have been deploying metadata with change sets or the metadata API and wondering whether there is a better way, there is. Unlocked Packages give you version control over your metadata, dependency management between components, and repeatable installations across orgs. They represent a shift from “push whatever changed” to “build and install a defined unit of functionality.” By the end of this post, you will understand what unlocked packages are, how to create and update them, and you will walk through a hands-on project that takes you from an empty SFDX project to a versioned package installed in a scratch org.
What Are Unlocked Packages?
An unlocked package is a container for a set of related Salesforce metadata — Apex classes, triggers, custom objects, fields, Lightning Web Components, permission sets, and anything else that makes up a feature or application. The word “unlocked” means that after a package is installed in an org, the metadata inside it can still be modified directly in that org. This is in contrast to managed packages, where the metadata is locked down and controlled entirely by the package developer.
The Three Package Types
Salesforce offers three types of packages, and it is important to understand where unlocked packages fit in:
-
Unmanaged Packages — The original packaging model. These are essentially bundles of metadata that you install once. After installation, the metadata becomes part of the target org with no link back to the original package. There is no versioning, no upgrade path, and no dependency tracking. Unmanaged packages are useful for sharing templates or sample code, but they are not suitable for ongoing development.
-
Managed Packages — Designed for ISVs (Independent Software Vendors) who distribute applications on the AppExchange. Managed packages are locked after installation. Subscribers cannot see or modify the Apex code. The package developer controls upgrades, and Salesforce enforces namespace isolation. Building managed packages requires a packaging org and a namespace.
-
Unlocked Packages — The middle ground. They give you versioning, upgrade paths, and dependency management like managed packages, but without the locking restrictions. Your team can install a version of the package and still modify the metadata in the target org if needed. Unlocked packages are built for internal development teams who want to organize their work into modular, deployable units.
Why Unlocked Packages Matter
Without packages, most Salesforce teams deploy metadata as a loose collection of components. A developer changes an Apex class, a custom field, and a permission set, then pushes them to a sandbox or production using a change set or the CLI. The problem is that there is no record of what was deployed together, no way to roll back to a previous state, and no way to express that “these 15 components belong together as a feature.”
Unlocked packages solve this by treating a group of related metadata as a single deployable artifact with a version number. Here is what that gives you:
- Versioning — Every time you create a new package version, Salesforce assigns it a unique subscriber package version ID. You can install specific versions in different orgs and track exactly what is running where.
- Dependency Management — A package can declare dependencies on other packages. If your “Sales Analytics” package depends on your “Core Data Model” package, Salesforce enforces that the dependency is installed first.
- Repeatable Deployments — Instead of tracking individual metadata components, you install a package version. The same version can be installed in QA, UAT, and production, giving you confidence that each environment has the same set of components.
- Modular Architecture — Unlocked packages encourage you to break your org’s metadata into logical modules. One package for your data model, another for your business logic, another for your Lightning components. This modularity makes large orgs easier to manage and reason about.
- Upgrade Path — When you create a new version of a package, you can install it over the previous version. Salesforce handles the upgrade, adding new components and modifying existing ones.
Unlocked Packages vs. Change Sets
If you are coming from a change set workflow, here is a direct comparison:
| Feature | Change Sets | Unlocked Packages |
|---|---|---|
| Versioning | No | Yes |
| Rollback | Manual | Install previous version |
| Dependencies | No tracking | Declared and enforced |
| Source of truth | Org | Source control (Git) |
| Repeatability | Low | High |
| Automation | Limited | Full CLI support |
| Modular design | No | Yes |
Change sets are fine for small teams with simple orgs. But as your org grows and your team scales, unlocked packages provide the structure and repeatability that change sets cannot.
How to Create an Unlocked Package
Creating an unlocked package involves a few steps: setting up your SFDX project, enabling Dev Hub, creating the package definition, adding metadata, and building a version. Let us walk through each step.
Prerequisites
Before you can create unlocked packages, you need:
- A Dev Hub org — This is the org that manages your packages and scratch orgs. You enable Dev Hub in Setup under “Dev Hub” in the quick find box. Typically, your production org or a dedicated developer edition org serves as the Dev Hub.
- Salesforce CLI installed — You need the
sfCLI. If you followed Part 67, you already have this. - A connected Dev Hub — Authenticate your Dev Hub org with the CLI:
sf org login web --set-default-dev-hub --alias my-devhub
- An SFDX project — If you do not have one, create it:
sf project generate --name my-package-project
cd my-package-project
Understanding sfdx-project.json
The sfdx-project.json file is the heart of your SFDX project. It defines your project structure, package directories, and package configurations. When you generate a new project, it looks something like this:
{
"packageDirectories": [
{
"path": "force-app",
"default": true
}
],
"name": "my-package-project",
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "61.0"
}
The packageDirectories array is where you define the directories that contain your package metadata. Each directory can be associated with a package. The namespace field is left empty for unlocked packages unless you specifically want to use one (most teams do not).
Creating the Package
To create an unlocked package, use the sf package create command:
sf package create \
--name "My Feature Package" \
--package-type Unlocked \
--path force-app \
--description "Contains the core feature metadata" \
--no-namespace
Let us break down the flags:
--name— The human-readable name of the package. This is what shows up in the Dev Hub and in installed orgs.--package-type— Set toUnlockedfor unlocked packages. The other option isManaged.--path— The directory in your project that contains the metadata for this package. This must match one of the paths inpackageDirectories.--description— An optional description.--no-namespace— Creates the package without a namespace. This is the most common choice for internal packages.
After running this command, your sfdx-project.json is updated automatically:
{
"packageDirectories": [
{
"path": "force-app",
"default": true,
"package": "My Feature Package",
"versionName": "ver 0.1",
"versionNumber": "0.1.0.NEXT"
}
],
"name": "my-package-project",
"namespace": "",
"sfdcLoginUrl": "https://login.salesforce.com",
"sourceApiVersion": "61.0",
"packageAliases": {
"My Feature Package": "0Ho..."
}
}
Notice that Salesforce added a packageAliases section with a package ID (starting with 0Ho). This ID uniquely identifies your package in the Dev Hub. The versionNumber uses the format MAJOR.MINOR.PATCH.BUILD, where NEXT tells Salesforce to auto-increment the build number when you create a version.
Adding Metadata to Your Package
Now you need to add the actual metadata that your package will contain. Place your Apex classes, custom objects, LWC components, and other metadata inside the directory specified by --path (in this case, force-app).
For example, create a simple Apex class:
sf apex generate class --name AccountService --output-dir force-app/main/default/classes
This creates AccountService.cls and AccountService.cls-meta.xml in the classes directory. Edit the class to add some logic:
public with sharing class AccountService {
public static List<Account> getActiveAccounts() {
return [
SELECT Id, Name, Industry, AnnualRevenue
FROM Account
WHERE IsActive__c = true
ORDER BY Name
LIMIT 100
];
}
public static void updateAccountIndustry(List<Id> accountIds, String newIndustry) {
List<Account> accounts = [
SELECT Id, Industry
FROM Account
WHERE Id IN :accountIds
];
for (Account acc : accounts) {
acc.Industry = newIndustry;
}
update accounts;
}
}
You would also want a test class to meet the 75% code coverage requirement:
@isTest
private class AccountServiceTest {
@TestSetup
static void setup() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 5; i++) {
accounts.add(new Account(
Name = 'Test Account ' + i,
Industry = 'Technology'
));
}
insert accounts;
}
@isTest
static void testGetActiveAccounts() {
List<Account> results = AccountService.getActiveAccounts();
System.assertNotEquals(null, results, 'Results should not be null');
}
@isTest
static void testUpdateAccountIndustry() {
List<Account> accounts = [SELECT Id FROM Account];
List<Id> accountIds = new List<Id>();
for (Account acc : accounts) {
accountIds.add(acc.Id);
}
Test.startTest();
AccountService.updateAccountIndustry(accountIds, 'Finance');
Test.stopTest();
List<Account> updated = [
SELECT Industry FROM Account WHERE Id IN :accountIds
];
for (Account acc : updated) {
System.assertEquals('Finance', acc.Industry, 'Industry should be updated');
}
}
}
Creating a Package Version
Once your metadata is in place, create a version of your package:
sf package version create \
--package "My Feature Package" \
--installation-key test1234 \
--wait 10 \
--code-coverage
The flags:
--package— The name or alias of the package.--installation-key— A password that must be provided when installing this version. Use--installation-key-bypassif you do not want a password.--wait— How many minutes to wait for the version creation to complete. Package version creation happens asynchronously on Salesforce’s servers and can take several minutes.--code-coverage— Runs your Apex tests during version creation and enforces the 75% code coverage requirement. This is required if you want to promote the version to released status later.
When the version is created, the CLI outputs a subscriber package version ID (starting with 04t). This is the ID you use to install the package in other orgs. Your sfdx-project.json is updated with an alias for the new version:
"packageAliases": {
"My Feature Package": "0Ho...",
"My Feature Package@0.1.0-1": "04t..."
}
Listing Your Package Versions
You can view all versions of your package:
sf package version list --packages "My Feature Package"
This shows the version number, subscriber package version ID, installation key status, and whether the version has been released.
How to Update an Unlocked Package
Software is never done. After you create the initial version of your package, you will inevitably need to add new features, fix bugs, and make changes. Unlocked packages handle this through version creation and promotion.
Making Changes
The process for updating a package is straightforward:
- Make changes to the metadata in your package directory.
- Update the version number in
sfdx-project.json. - Create a new package version.
- Install the new version in target orgs.
For example, suppose you want to add a new method to your AccountService class:
public static Account createAccount(String name, String industry) {
Account acc = new Account(
Name = name,
Industry = industry
);
insert acc;
return acc;
}
After making the change, update the version number in sfdx-project.json:
{
"path": "force-app",
"default": true,
"package": "My Feature Package",
"versionName": "ver 0.2",
"versionNumber": "0.2.0.NEXT"
}
Then create a new version:
sf package version create \
--package "My Feature Package" \
--installation-key test1234 \
--wait 10 \
--code-coverage
Version Numbering Strategy
The version number format is MAJOR.MINOR.PATCH.BUILD:
- MAJOR — Increment for breaking changes or major new features. When you bump the major version, orgs that have the previous major version installed may need migration steps.
- MINOR — Increment for backward-compatible feature additions.
- PATCH — Increment for bug fixes.
- BUILD — Auto-incremented by Salesforce when you use
NEXT.
A common convention:
1.0.0.NEXT— Initial release.1.1.0.NEXT— Added a new feature.1.1.1.NEXT— Fixed a bug in the new feature.2.0.0.NEXT— Major restructuring, possibly breaking changes.
Promoting a Package Version
By default, new package versions are in beta status. Beta versions can only be installed in sandbox and scratch orgs. To install a version in a production org, you must promote it:
sf package version promote --package "My Feature Package@0.2.0-1"
Once promoted, a version is released and cannot be deleted. The promotion step is intentional — it forces you to test your package in non-production environments before making it available for production. Promotion also requires that the version was created with --code-coverage and that the Apex tests pass with at least 75% coverage.
Installing an Updated Version
To upgrade an org from one version to another, install the new version:
sf package install \
--package "My Feature Package@0.2.0-1" \
--installation-key test1234 \
--target-org my-sandbox \
--wait 10
Salesforce handles the upgrade automatically. New components are added, existing components are updated, and components that were removed from the package are flagged (though not automatically deleted from the org — more on that below).
Handling Removed Components
One important behavior to understand: when you remove a component from your package source and create a new version, that component is not automatically removed from orgs where the previous version was installed. Salesforce takes a conservative approach here. The component becomes “unpackaged” in the target org, meaning it is no longer managed by the package but still exists.
If you need to clean up removed components, you have a few options:
- Manually delete them from the target org.
- Use destructive changes alongside your package installation.
- Use the
--purge-on-deleteflag during development (scratch orgs only).
Managing Dependencies Between Packages
If you have multiple packages and one depends on another, you declare the dependency in sfdx-project.json:
{
"packageDirectories": [
{
"path": "force-app-core",
"default": true,
"package": "Core Data Model",
"versionName": "ver 1.0",
"versionNumber": "1.0.0.NEXT"
},
{
"path": "force-app-features",
"package": "Business Logic",
"versionName": "ver 1.0",
"versionNumber": "1.0.0.NEXT",
"dependencies": [
{
"package": "Core Data Model",
"versionNumber": "1.0.0.LATEST"
}
]
}
]
}
When you create a version of “Business Logic,” Salesforce verifies that the dependency on “Core Data Model” is satisfied. When you install “Business Logic” in an org, the CLI checks that “Core Data Model” is already installed. If it is not, the installation fails with a clear error message.
PROJECT: Create an Unlocked Package and Deploy It to a Scratch Org
Now let us put everything together. In this project, you will create an SFDX project from scratch, define an unlocked package, add metadata to it, create a version, and install it in a scratch org. This mirrors a realistic development workflow.
Step 1: Set Up the Project
Open your terminal and create a new SFDX project:
sf project generate --name expense-tracker
cd expense-tracker
Authenticate your Dev Hub if you have not already:
sf org login web --set-default-dev-hub --alias devhub
Step 2: Define the Package
Create the unlocked package:
sf package create \
--name "Expense Tracker" \
--package-type Unlocked \
--path force-app \
--description "Tracks employee expenses with approval workflow" \
--no-namespace
Verify that your sfdx-project.json was updated:
cat sfdx-project.json
You should see the package alias and version information added to the file.
Step 3: Add Custom Object Metadata
Create the directory structure for a custom object called Expense__c:
mkdir -p force-app/main/default/objects/Expense__c/fields
Create the object definition at force-app/main/default/objects/Expense__c/Expense__c.object-meta.xml:
<?xml version="1.0" encoding="UTF-8"?>
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
<deploymentStatus>Deployed</deploymentStatus>
<label>Expense</label>
<nameField>
<label>Expense Name</label>
<type>AutoNumber</type>
<displayFormat>EXP-{0000}</displayFormat>
</nameField>
<pluralLabel>Expenses</pluralLabel>
<sharingModel>ReadWrite</sharingModel>
</CustomObject>
Create a custom field at force-app/main/default/objects/Expense__c/fields/Amount__c.field-meta.xml:
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Amount__c</fullName>
<label>Amount</label>
<type>Currency</type>
<precision>18</precision>
<scale>2</scale>
<required>true</required>
</CustomField>
Create another field at force-app/main/default/objects/Expense__c/fields/Status__c.field-meta.xml:
<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
<fullName>Status__c</fullName>
<label>Status</label>
<type>Picklist</type>
<required>false</required>
<valueSet>
<valueSetDefinition>
<sorted>false</sorted>
<value>
<fullName>Pending</fullName>
<default>true</default>
<label>Pending</label>
</value>
<value>
<fullName>Approved</fullName>
<default>false</default>
<label>Approved</label>
</value>
<value>
<fullName>Rejected</fullName>
<default>false</default>
<label>Rejected</label>
</value>
</valueSetDefinition>
</valueSet>
</CustomField>
Step 4: Add an Apex Service Class
Generate the Apex class:
sf apex generate class --name ExpenseService --output-dir force-app/main/default/classes
Replace the contents of force-app/main/default/classes/ExpenseService.cls:
public with sharing class ExpenseService {
public static Expense__c createExpense(String name, Decimal amount) {
Expense__c expense = new Expense__c(
Amount__c = amount,
Status__c = 'Pending'
);
insert expense;
return expense;
}
public static List<Expense__c> getPendingExpenses() {
return [
SELECT Id, Name, Amount__c, Status__c, CreatedDate
FROM Expense__c
WHERE Status__c = 'Pending'
ORDER BY CreatedDate DESC
];
}
public static void approveExpenses(List<Id> expenseIds) {
List<Expense__c> expenses = [
SELECT Id, Status__c
FROM Expense__c
WHERE Id IN :expenseIds AND Status__c = 'Pending'
];
for (Expense__c exp : expenses) {
exp.Status__c = 'Approved';
}
update expenses;
}
public static void rejectExpenses(List<Id> expenseIds) {
List<Expense__c> expenses = [
SELECT Id, Status__c
FROM Expense__c
WHERE Id IN :expenseIds AND Status__c = 'Pending'
];
for (Expense__c exp : expenses) {
exp.Status__c = 'Rejected';
}
update expenses;
}
public static Decimal getTotalPendingAmount() {
AggregateResult result = [
SELECT SUM(Amount__c) total
FROM Expense__c
WHERE Status__c = 'Pending'
];
Decimal total = (Decimal) result.get('total');
return total != null ? total : 0;
}
}
Step 5: Add a Test Class
Generate the test class:
sf apex generate class --name ExpenseServiceTest --output-dir force-app/main/default/classes
Replace the contents of force-app/main/default/classes/ExpenseServiceTest.cls:
@isTest
private class ExpenseServiceTest {
@TestSetup
static void setup() {
List<Expense__c> expenses = new List<Expense__c>();
for (Integer i = 1; i <= 5; i++) {
expenses.add(new Expense__c(
Amount__c = i * 100,
Status__c = 'Pending'
));
}
insert expenses;
}
@isTest
static void testCreateExpense() {
Test.startTest();
Expense__c result = ExpenseService.createExpense('Office Supplies', 250.00);
Test.stopTest();
System.assertNotEquals(null, result.Id, 'Expense should be inserted');
System.assertEquals(250.00, result.Amount__c, 'Amount should match');
System.assertEquals('Pending', result.Status__c, 'Status should default to Pending');
}
@isTest
static void testGetPendingExpenses() {
Test.startTest();
List<Expense__c> results = ExpenseService.getPendingExpenses();
Test.stopTest();
System.assertEquals(5, results.size(), 'Should return 5 pending expenses');
}
@isTest
static void testApproveExpenses() {
List<Expense__c> expenses = [SELECT Id FROM Expense__c LIMIT 3];
List<Id> expenseIds = new List<Id>();
for (Expense__c exp : expenses) {
expenseIds.add(exp.Id);
}
Test.startTest();
ExpenseService.approveExpenses(expenseIds);
Test.stopTest();
List<Expense__c> approved = [
SELECT Status__c FROM Expense__c WHERE Id IN :expenseIds
];
for (Expense__c exp : approved) {
System.assertEquals('Approved', exp.Status__c, 'Status should be Approved');
}
}
@isTest
static void testRejectExpenses() {
List<Expense__c> expenses = [SELECT Id FROM Expense__c LIMIT 2];
List<Id> expenseIds = new List<Id>();
for (Expense__c exp : expenses) {
expenseIds.add(exp.Id);
}
Test.startTest();
ExpenseService.rejectExpenses(expenseIds);
Test.stopTest();
List<Expense__c> rejected = [
SELECT Status__c FROM Expense__c WHERE Id IN :expenseIds
];
for (Expense__c exp : rejected) {
System.assertEquals('Rejected', exp.Status__c, 'Status should be Rejected');
}
}
@isTest
static void testGetTotalPendingAmount() {
Test.startTest();
Decimal total = ExpenseService.getTotalPendingAmount();
Test.stopTest();
// 100 + 200 + 300 + 400 + 500 = 1500
System.assertEquals(1500, total, 'Total pending amount should be 1500');
}
}
Step 6: Create a Scratch Org
Define your scratch org configuration. Edit config/project-scratch-def.json:
{
"orgName": "Expense Tracker Dev",
"edition": "Developer",
"features": [],
"settings": {
"lightningExperienceSettings": {
"enableS1DesktopEnabled": true
}
}
}
Create the scratch org:
sf org create scratch \
--definition-file config/project-scratch-def.json \
--alias expense-dev \
--duration-days 7 \
--set-default
Step 7: Create the Package Version
Now create the first version of your package:
sf package version create \
--package "Expense Tracker" \
--installation-key mykey123 \
--wait 15 \
--code-coverage
This step takes several minutes. Salesforce spins up a temporary org behind the scenes, deploys your metadata, runs your tests, calculates code coverage, and if everything passes, produces a package version. You will see progress updates in the terminal.
When it completes, you get output like:
Successfully created the package version [08c...]
Subscriber Package Version Id: 04t...
Take note of the 04t ID. That is your installable package version.
Step 8: Install the Package in the Scratch Org
Install the newly created package version in your scratch org:
sf package install \
--package "Expense Tracker@0.1.0-1" \
--installation-key mykey123 \
--target-org expense-dev \
--wait 10
You can also use the 04t ID directly:
sf package install \
--package 04tXXXXXXXXXXXXXXX \
--installation-key mykey123 \
--target-org expense-dev \
--wait 10
Step 9: Verify the Installation
Open the scratch org and verify that everything is there:
sf org open --target-org expense-dev
In the org, navigate to Setup > Installed Packages. You should see “Expense Tracker” listed with version 0.1.0. You can also verify programmatically:
sf package installed list --target-org expense-dev
This command outputs a table showing all installed packages, their version numbers, and their subscriber package version IDs.
To verify your Apex class is available, run the tests:
sf apex run test \
--class-names ExpenseServiceTest \
--target-org expense-dev \
--result-format human \
--wait 5
All five test methods should pass.
Step 10: Update and Reinstall
Let us simulate an update cycle. Add a new method to ExpenseService.cls:
public static Map<String, Integer> getExpenseCountByStatus() {
Map<String, Integer> countMap = new Map<String, Integer>();
List<AggregateResult> results = [
SELECT Status__c, COUNT(Id) cnt
FROM Expense__c
GROUP BY Status__c
];
for (AggregateResult ar : results) {
countMap.put((String) ar.get('Status__c'), (Integer) ar.get('cnt'));
}
return countMap;
}
Add a corresponding test method to ExpenseServiceTest.cls:
@isTest
static void testGetExpenseCountByStatus() {
Test.startTest();
Map<String, Integer> counts = ExpenseService.getExpenseCountByStatus();
Test.stopTest();
System.assertEquals(5, counts.get('Pending'), 'Should have 5 pending expenses');
}
Update the version number in sfdx-project.json to 0.2.0.NEXT, then create and install the new version:
sf package version create \
--package "Expense Tracker" \
--installation-key mykey123 \
--wait 15 \
--code-coverage
sf package install \
--package "Expense Tracker@0.2.0-1" \
--installation-key mykey123 \
--target-org expense-dev \
--wait 10
After installation, run the tests again to confirm the new method works:
sf apex run test \
--class-names ExpenseServiceTest \
--target-org expense-dev \
--result-format human \
--wait 5
You now have a working package development lifecycle: develop locally, create a version, install it, verify it, iterate.
Best Practices for Unlocked Packages
Before wrapping up, here are some practices that will serve you well as you adopt unlocked packages in your workflow:
- One package per feature or domain — Do not put your entire org into one giant package. Break your metadata into logical units. A common pattern is to have a “core” package with shared objects and utilities, and separate packages for each feature area.
- Always use code coverage — Create versions with
--code-coveragefrom the start. It catches test failures early and ensures your package is always promotable. - Use meaningful version numbers — Follow semantic versioning conventions. Your team should be able to look at a version number and understand the scope of changes.
- Automate with CI/CD — Package version creation and installation can be scripted and integrated into your CI/CD pipeline. Tools like GitHub Actions, GitLab CI, and Jenkins work well with the Salesforce CLI.
- Test upgrades, not just fresh installs — Installing a package in a clean org is different from upgrading an existing installation. Test both scenarios.
- Keep your sfdx-project.json in source control — This file is your package manifest. It should be committed to Git and reviewed in pull requests just like any other code.
- Use scratch orgs for development and testing — Scratch orgs give you a clean environment to test package installations and upgrades without affecting shared sandboxes.
Section Notes
- Unlocked packages are Salesforce’s recommended way to organize, version, and deploy metadata for internal development teams. They provide versioning, dependency management, and repeatable deployments without the locking restrictions of managed packages.
- The sfdx-project.json file is the central configuration for your packages. It defines package directories, version numbers, dependencies, and aliases.
- Use
sf package createto define a new package, andsf package version createto build a versioned, installable artifact from your metadata. - Package versions start in beta status and must be promoted before they can be installed in production orgs. Promotion requires passing Apex tests with at least 75% code coverage.
- Dependencies between packages are declared in
sfdx-project.jsonand enforced by Salesforce during version creation and installation. - When you update a package, you increment the version number, create a new version, and install it over the previous version in target orgs.
- Removed components are not automatically deleted from target orgs during an upgrade. Plan for cleanup separately.
- Unlocked packages work best when combined with scratch orgs for development, Git for source control, and CI/CD pipelines for automation.
- The hands-on project demonstrated the full lifecycle: project setup, package creation, metadata authoring, version creation, scratch org installation, verification, and iterative updates.