← Back to Field Notes

Building a 2GP Managed Package from Scratch

·8 min read

I've shipped a 2GP managed package end-to-end through AppExchange security review, and I've supported three other ISVs through the same process. The official documentation tells you what each command does. It doesn't tell you the order to do them in, what to skip, or where the time actually goes. This is the version of the playbook I'd hand to someone starting today.

If you're considering 2GP versus 1GP, the short answer is: 2GP, every time, no exceptions. 1GP is in maintenance mode, and the few advantages it had (push upgrade simplicity, namespace rules) are now matched in 2GP. Anything new should start in 2GP, and existing 1GP packages should plan a migration.

The Namespace Decision Comes First

Before any code, before any package version, you need a namespace. This is the prefix that gets attached to every custom object, field, and Apex class in your package. Once you've registered it, you cannot change it — every customer install has the namespace baked into their data forever.

The namespace must be:

  • 1-15 characters
  • Letters and numbers only
  • Globally unique across all of Salesforce
  • Not used as the start of a customer's existing custom field

Pick something short, generic enough to outlast pivots, and not too close to your current product name. I've seen ISVs name their namespace acme1 because they thought their product name would last — and then they pivoted, and now every customer's database has acme1__ prefixes that don't match the new brand. Pick a namespace that's product-agnostic.

The registration happens in a Dev Hub org through the namespace registration form. It takes a few business days. Don't start packaging work until this is done; you can't change later.

The Project Structure That Actually Works

Once you have a namespace, the project structure I use is:

my-package/
├── sfdx-project.json          ← Package definition
├── force-app/
│   └── main/
│       └── default/           ← Source code (Apex, LWC, custom objects)
├── data/
│   ├── seed/                  ← Test data scripts for scratch orgs
│   └── samples/               ← Demo data for onboarding
├── scripts/
│   ├── package-create.sh      ← Create new package version
│   ├── install-locally.sh     ← Install latest in personal org
│   └── promote.sh             ← Promote version to released
├── docs/
│   ├── security-review-prep/  ← Security questionnaire drafts
│   └── post-install/          ← Setup steps for subscribers
└── .github/workflows/
    ├── pr-validate.yml        ← Run tests on PR
    ├── package-version.yml    ← Build package version on merge
    └── promote.yml            ← Manual workflow to promote

The sfdx-project.json is where the package metadata lives. The minimal version:

{
  "packageDirectories": [
    {
      "path": "force-app",
      "default": true,
      "package": "MyPackage",
      "versionName": "ver 0.1",
      "versionNumber": "0.1.0.NEXT",
      "definitionFile": "config/project-scratch-def.json"
    }
  ],
  "namespace": "myns",
  "sourceApiVersion": "60.0",
  "packageAliases": {
    "MyPackage": "0Ho..."
  }
}

The 0Ho... ID is generated when you first create the package via sf package create. After that, all subsequent versions are tracked through packageAliases.

The Version Pipeline

The cadence I run for an ISV product:

On every PR: scratch org spin-up, source push, Apex tests run, results posted to PR. This catches obvious failures before review.

On merge to main: a new Beta package version is created automatically. The package version ID is recorded in sfdx-project.json and committed back. This makes every merge installable for testing.

On a manual workflow trigger (when ready to release): a Beta version is promoted to a released version. Promotion is a one-way operation — released versions can never be deleted. This is the gate I'm careful about.

The package-version.yml workflow looks roughly like:

name: Build Package Version
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install sf CLI
        run: npm install --global @salesforce/cli
      - name: Auth Dev Hub
        run: |
          echo "${{ secrets.SF_AUTH_URL }}" > authurl.txt
          sf org login sfdx-url --sfdx-url-file authurl.txt --alias devhub --set-default-dev-hub
      - name: Create package version
        run: sf package version create --package MyPackage --installation-key-bypass --code-coverage --wait 30
      - name: Commit updated alias
        run: |
          git add sfdx-project.json
          git commit -m "Update package version alias [skip ci]"
          git push

This is enough for an ISV's day-to-day. More advanced setups handle ancestor versioning explicitly, but for the first version you can let the platform default.

Ancestor Versioning — Get This Right Once

When a customer has v1.0 installed and you want to install v1.1, the platform needs to know that v1.1 is an upgrade of v1.0 — not an unrelated package. This is what ancestors handle.

In sfdx-project.json, you set the ancestor:

{
  "packageDirectories": [
    {
      "package": "MyPackage",
      "versionName": "ver 1.1",
      "versionNumber": "1.1.0.NEXT",
      "ancestorVersion": "1.0.0.LATEST"
    }
  ]
}

The 1.0.0.LATEST says "the latest released version in the 1.0.x line is the parent." When v1.1 is installed over v1.0, the platform performs an upgrade rather than a fresh install.

The trap: if you ever release a version without an ancestor, the chain breaks for that branch. Customers on v1.0 can't upgrade to v1.1 without uninstall-reinstall. This is painful, especially in production. Always set the ancestor for every released version.

Security Review Is the Real Project

The packaging work is mechanical. The security review is where the project length lives. Plan for:

  • 2-4 weeks of remediation for first-time submitters, even with clean code
  • Multiple submission cycles — first submissions almost never pass
  • A detailed security questionnaire that explains every architectural decision

I wrote separately about CRUD/FLS enforcement, which is the most common scanner finding. The other categories that catch out ISVs:

  • Stored XSS via custom field rendering — Lightning components that render unsafe HTML from custom fields
  • CSRF via Visualforce pages — pages that perform state changes without verification tokens
  • Hardcoded credentials — connected app secrets, API keys, OAuth refresh tokens checked into the package
  • Insufficient sharing enforcement — with sharing missing or without sharing used without justification
  • Insecure SOQL injection vectors — dynamic SOQL with user-supplied values

The security questionnaire is the document that ties findings to remediation. Write it carefully. Reviewers want to see thinking, not boilerplate. The package I prepared for Annature passed first review largely on the strength of a thorough questionnaire.

Subscriber Org Constraints I Forgot About First Time

Some constraints don't appear until customers install your package in real orgs:

Field-Level Security defaults. Custom fields in your package are not visible to subscriber profiles by default. Customers have to manually grant FLS through permission sets. Plan to ship a permission set inside the package.

Object permissions vs sharing. Even if FLS is granted, the subscriber's sharing rules apply to your records. If you create child records expecting them to be visible to the parent's owner, you have to handle sharing explicitly.

Apex code limits. Subscribers have aggregate Apex code limits across all packages installed. Bloated Apex in your package can prevent subscribers from installing other packages or writing their own code. Watch the package's compiled Apex size.

Dependencies on customer-specific metadata. If your code references a standard field that's optional in some Salesforce editions (e.g., Account.OwnerId vs Person Account fields), check the field exists at runtime via Schema.SObjectType introspection. Hardcoded references break in editions where the field doesn't exist.

The LMA Setup You'll Forget

The License Management App (LMA) is what you use to track which customers have your package installed and what license tier they're on. Setting it up is fiddly:

  • Request access to the LMA from Salesforce Partners
  • Install in your packaging org
  • Configure the License object to track your customer base
  • Set up Apex hooks in your package to enforce license tiers (if you have multiple tiers)

The LMA is also how you push upgrades. Once a version is promoted to released, you can push it to all subscriber orgs of a particular customer or tier. This is powerful and easy to misuse — push upgrades that fail in subscriber orgs are a support nightmare.

I push upgrades only after the version has been in the wild for at least a week with active customer testing. Never on a Friday. Never to the entire customer base at once.

What I'd Skip on the First Package

A few things I'd recommend deferring on a first package:

Multi-tier licensing logic baked into Apex. Build the v1 with a single license tier. Add tier differentiation in v2 once you understand which features customers actually want gated.

Custom domain and connected app for OAuth. Use the platform's built-in OAuth flow for v1. The custom domain is a security review surface area that's not worth the cost early.

Internationalisation. Custom labels are fine for v1. Translate when you have non-English customers, not before.

Unit tests for non-public methods. Test through the public API. Pop the hood only when there's a specific bug to chase. The security scanner doesn't care about internal coverage; it cares that the public surface is safe.

If you're scoping out an AppExchange package and want a second pair of eyes on architecture, packaging strategy, or security posture, that's the kind of advisory work I do — most of the avoidable cost in ISV projects comes from setting up the package wrong in week one.

The shortest summary: get the namespace right, build for upgrade-ability from v1, plan security review as a multi-month effort not a final step, and don't try to be clever in the first version. The clever versions come after the customers do.

Liked this?

New essays in your inbox when I publish. No cadence, no spam.