Skip to main content

How to Wire Multiple Salesforce Projects in One Org Without Breaking Everything

About Us
Published by yuliya.dzemidchuk
08 July 2026

Integrating Salesforce Projects in a Shared Org

 

Introduction

A single Salesforce org rarely hosts a single application. In a mature internal platform it is normal to find several products living side by side, each with its own Experience Cloud site, its own data model, and its own release cadence. Some of those products are installed as managed packages with their own namespace; others are first-party code deployed directly into the org. The interesting and dangerous part is not any one of them in isolation — it is the seams between them.

This article describes the integration patterns that appear when four such projects share one org: Capacity, Leave Planner, Smart Approval, and Karma. Three of them expose a dedicated site; Smart Approval is the approval engine the Leave Planner is built on. Two of them — Smart Approval and Karma — ship as managed packages. They do not stay in their lanes: the Leave Planner writes records into Capacity, Capacity reads data from Karma, and the Leave Planner reacts to objects owned by the approval package. The goal here is to map how that cross-project wiring is actually built, how access is granted to both authenticated users and unauthenticated site guest users, and what breaks when one of those packages later has to be upgraded or removed.

The patterns are not specific to these four apps. The same forces apply to any org where packaged and unpackaged code reference each other across namespace boundaries.

 

The Landscape: Four Projects, One Org

Before discussing the seams, it helps to fix the actors. Each project owns a set of objects, exposes a site, and is either a managed package (with a namespace prefix that the rest of the org must spell out explicitly) or unpackaged metadata.

Project

Packaging

Namespace

Key objects

Capacity

Unpackaged

— (org default)

Project__c, Project_Resource__c, Loading_Check__c

Leave Planner

Unpackaged

— (org default)

Leave_Request__c, Vacation_Period__c, External_Tasks__c

Smart Approval

Managed package

smartapproval__

smartapproval__Approval_Request__c, smartapproval__Approver__c

Karma

Managed package

karma__

karma__Karma_Entry__c, karma__Giver__c, karma__Person__c

The approval engine behind the Leave Planner is the Smart Approval managed package. Karma is a managed package as well. Because both carry a namespace, every reference to their objects, fields, and exposed methods from outside the package must be fully qualified — and, as the risk section will show, that single fact is the root of most of the coupling problems.

 

Pattern 1: Custom Triggers on Package-Owned Objects

The first and most common integration pattern is attaching first-party automation to an object that a managed package owns. The Leave Planner needs to react when an approval is granted, but the approval record itself, smartapproval__Approval_Request__c, belongs to Smart Approval. The org cannot edit the package's internal triggers, so it adds its own trigger on the package object and routes the work into an unpackaged handler.

In practice that means a handler such as ApprovalRequestTriggerHandler fires on update of the package object, inspects the package field smartapproval__Status__c, and when it transitions to Approved or Declined, follows the package's smartapproval__Record_Id__c pointer back to the corresponding Leave_Request__c to send instruction emails and update Leave Planner state. A sibling handler, ApproverTriggerHandler, fires on insert of smartapproval__Approver__c, counts the child approvers against smartapproval__Number_of_Approvals_Required__c, and only then creates the downstream Weenvo tasks.

Why this works, and where it strains:

  • The package object becomes an event source for unpackaged logic. That is powerful — it lets the org extend a closed product without touching its code.
  • But there is no guaranteed ordering between the package's own triggers/flows and the org's custom trigger on the same object. Both run in the same transaction, and half the stack is hidden managed code that does not appear in debug logs.
  • Recursion control is shared and fragile. Seeing setMaxLoopCount(10) on a handler is a smell: it usually means the custom trigger and the package automation are re-entering each other, and the loop ceiling is a workaround rather than a design.
  • The handler depends on package field API names and child relationship names (smartapproval__Approver__r). If the package renames or retires one of those in a future version, the trigger breaks on upgrade.

 

Pattern 2: Calling Global Members Exposed by a Package

A managed package is a black box by default. Only members explicitly declared global are visible across the namespace boundary; anything public is invisible to code outside the package. This is why a package author has to deliberately design a public surface, and why the consuming org is at the mercy of that decision.

The Leave Planner uses this directly for authentication on its public site. Its Visualforce controller, ApprovalRequestDetailsController, delegates the entire Google OAuth dance to a global utility shipped inside Smart Approval:

  • smartapproval.GoogleAuthUtils.LoginWithGoogle(REDIRECT_URL)  — starts the OAuth redirect.
  • smartapproval.GoogleAuthUtils.getContactFromAuthenticatedGoogleEmail(REDIRECT_URL)  — resolves the authenticated email back to a Contact.

The benefit is obvious: the Leave Planner reuses a tested authentication flow instead of re-implementing OAuth. The cost is a hard, named dependency on a global contract that only the package author controls. If a future package version changes the signature of LoginWithGoogle, drops it from the global surface, or alters its redirect behaviour, the Leave Planner's login page stops working until the org adapts. Global API is a contract — but it is the package's contract, not the org's.

 

Pattern 3: Writing Another Project’s Objects Directly

This is the most consequential pattern in the platform, and the one the rest of the article keeps returning to. Normally a Project_Resource__c record — the unit of allocation that drives the Capacity grid — is created by a person, manually, through the Capacity site UI. The Leave Planner breaks that assumption. When a leave request is created or approved, a trigger handler in the Leave Planner reaches across the seam and inserts Project_Resource__c records directly, so that an approved vacation shows up as load on the Capacity board without anyone touching the Capacity site.

How the write path works:

  1. LeaveRequestTriggerHandler resolves the special vacation project at runtime by reading a Custom Setting key, Capacity_General_Settings__c.getValues('JIRA_Project_Key_Vacation'), and querying the matching Project__c. The link between the two projects is configuration, not a hardcoded Id.
  2. For auto-approved leave types (illness, baby care, other) it inserts a single Project_Resource__c         immediately. For approval-gated types it waits for the approval and then builds one record per ISO week, splitting the range around weekends, setting Planned_Utilization__c = 100 and the "Assigned Resource" record type.
  3. The allocation status mirrors the leave status — Planned versus Confirmed — and each generated record carries a Leave_Request__c lookup back to its source, so the Leave Planner can find and clean up its own footprint later.
  4. On update it deletes and recreates the weekly records; on delete it cascades, removing the Project_Resource__c records and the related External_Tasks__c, and enqueues Weenvo task deletion.

The defining property of this pattern is that one object now has two independent producers: the Capacity UI and the Leave Planner trigger. Capacity's own invariants — overallocation checks like the checkHours guard, weekly loading-check generation, KPI rollups — were written assuming records arrive through Capacity's own paths. Records injected by the Leave Planner must satisfy those same invariants, even though the Leave Planner code knows nothing about them. The coupling is also by string name: the leave handler resolves the "Assigned Resource" record type and specific field names at runtime, so a rename on the Capacity side fails silently and far from where the change was made.

 

Pattern 4: Reading Another Package’s Objects, Guarded at Runtime

The Capacity grid also shows Karma points next to each resource, which means Capacity reads karma__Karma_Entry__c — an object owned by the Karma managed package. Because Karma may or may not be installed in a given org, Capacity guards the read at runtime. CapacityManagementHelper.isCheckKarmaAppInOrg() queries EntityDefinition for objects matching karma__% and only proceeds to query Karma data if the package is present; the Aura component renders the Karma column conditionally on that flag.

This is a thoughtful mitigation, but it is worth being precise about what it does and does not protect. The EntityDefinition check guards the runtime query path. It does not remove the compile-time dependency: the helper class still references karma__Karma_Entry__c by name, so the class will not deploy into an org where Karma is absent, and the package cannot be uninstalled while that reference exists. Runtime optionality and compile-time optionality are different problems, and only the latter actually lets you remove a package.

 

Access Control: Regular Users vs Site Guest Users

Each project has its own site, and each site has to answer two separate access questions: what may an authenticated, licensed user do, and what may an unauthenticated guest user do. The codebase solves these with a mix of profile checks, sharing modifiers, and a narrowly scoped privilege-elevation pattern.

 

Profile checks for authenticated users

Capacity gates its mutating operations with a coarse but explicit profile check: userHasAccess = UserInfo.getProfileId() == CAPACITY_PROFILE_ID. Each @AuraEnabled write method confirms userHasAccess before acting. It is blunt — it pins behaviour to a single profile name — but it is visible and easy to reason about.

 

Sharing modifiers and the elevation pattern

Most cross-project and site-facing controllers run without sharing so that automation and guest users can touch records they do not own. The Karma controllers, the Leave Planner handlers, the Capacity helper, and the approval handlers are all without sharing. The cleanest example of doing this responsibly is ProjectResourceHandler, which is itself with sharing but nests a small inner without sharing class, siteGuestUserUtilityHandler, that performs only the privileged SOQL and DML. The elevation is scoped to a handful of methods rather than the whole class — which is exactly the right instinct.

 

The Master-Detail wall

For most custom objects, granting a site guest user access is a permission-set exercise: add the object and field permissions to the guest user's permission set (or to the package's own permission set) and you are done. The wall appears when a custom object sits on the detail side of a Master-Detail relationship to a standard object.

A Master-Detail child has no organization-wide default and no independent ownership of its own. Its access is inherited from the master record, and the child's sharing is governed by the relationship's sharing setting rather than by ordinary object permissions. That has a concrete consequence: you cannot simply "add the object to a permission set" to give a guest user create or edit rights the way you can for a standalone custom object, and a package permission set in particular may not be able to express the grant at all. For a guest user — whose access model is already the most restricted in the platform — this is a genuine dead end.

The escape hatches each carry a cost, and they are why so much of this code runs without sharing:

Approach

Trade-off

without sharing Apex behind an @AuraEnabled / site controller

Bypasses the guest-user sharing constraints for that operation. Works, but the true access surface now lives in code, not in a permission set, so it is invisible to an admin auditing permissions.

Re-model Master-Detail as Lookup

Restores an independent OWD and lets permission sets grant access normally. Costs you roll-up summaries, cascade delete, and inherited sharing — often a large refactor.

Duplicate the data onto an accessible object

Sidesteps the relationship entirely, at the price of a sync mechanism and a second source of truth.

The pattern in this platform is the first row: privileged access is granted by without sharing Apex rather than declarative permissions. It is pragmatic, but it pushes the security model into code, which is the theme of the next section.

 

Configuration: Don’t Hardcode the Seams

A recurring good habit across the codebase is that the links between projects are configuration, not constants. The Leave Planner finds the Capacity vacation project through Capacity_General_Settings__c. Karma exclusions for the capacity calculation live in Capacity_Excluded_Projects_for_Karma__mdt. Weenvo user identifiers are stored on the Contact (Weenvo_User_ID__c) rather than mapped in code. This mirrors the rule from any external integration: environment-specific identifiers belong in Custom Metadata or Custom Settings, secrets belong in Named Credentials, and nothing that varies between sandbox and production should be compiled into Apex. The seams between projects are exactly the values most likely to differ, so they are exactly the ones that must not be hardcoded.

 

The Risks Nobody Puts in the Architecture Diagram

The patterns above all work in steady state. The trouble surfaces the day someone needs to upgrade Smart Approval, remove Karma, or change Capacity's data model. The following are the failure modes this style of integration creates.

1. Hard compile-time dependencies block uninstall

Unpackaged code references smartapproval__ and karma__ objects, fields, and global methods directly. Salesforce will not let you uninstall a managed package while any Apex outside it references the package's components. So the moment a custom trigger, class, field, or component names a package object, that package is effectively pinned in place. To remove Karma you would first have to find and neutralize every reference — the karma__Karma_Entry__c query in the Capacity helper, the conditional column in the Aura component, and anything else — before the platform will even offer the uninstall.

2. Upgrades can break or block the custom layer

A package upgrade is only safe for the parts of the global surface the author kept stable. If a new Smart Approval version changes the signature of GoogleAuthUtils.LoginWithGoogle, removes a global member, or renames smartapproval__Approver__c fields the org's triggers read, the dependent code breaks — or the upgrade itself is blocked because removing a referenced component would invalidate org metadata. The org's release schedule becomes coupled to the package's.

3. Triggers on package objects fight package automation

Running a custom trigger on smartapproval__Approval_Request__c means co-existing with whatever the package does on the same object, with no control over execution order and a shared recursion budget. Mutual re-entry between the two is the most likely cause of the setMaxLoopCount workaround, and debugging is painful because the managed half of the transaction is opaque.

4. Multiple writers, no single owner of invariants

Project_Resource__c is written by both the Capacity UI and the Leave Planner trigger. No single component owns its invariants. A validation, required field, or record-type change made by the Capacity team can silently break the Leave Planner, because the leave handler resolves names like "Assigned Resource" at runtime and only discovers the mismatch when an insert fails — far from the change that caused it. Conversely, records the Leave Planner injects may skip checks the Capacity UI performs, letting data drift below the surface.

5. “without sharing” is a blast radius

Elevating a whole class to without sharing to satisfy one guest-user or cross-project need removes record-level protection from everything that class does. Where the elevation is scoped to a small inner utility, as in ProjectResourceHandler, the blast radius is contained. Where an entire guest-exposed controller is without sharing, a single over-broad @AuraEnabled method can read or write far more than intended. The convenience that solves the Master-Detail wall is the same convenience that widens the attack surface.

6. Access granted in code is invisible to admins

When guest access is granted by without sharing Apex instead of a permission set, an administrator auditing the guest user's permission sets sees an understated picture of what the site can actually do. Least-privilege review and security audits become unreliable, because the real access model lives in classes, not in the permission model the audit tools inspect.

7. Cross-object cascades fail silently

The leave delete path cascades into Capacity (Project_Resource__c), into External_Tasks__c, and into an asynchronous Weenvo deletion. Several of these legs catch their exception and only System.debug it. If one leg fails, you are left with orphaned allocations on the Capacity board or stale Weenvo tasks, and no surfaced error — the cross-boundary version of the classic silent-failure anti-pattern.

8. Testing and deployment ordering

Because the custom layer references package objects, any scratch org or CI pipeline must install Smart Approval and Karma — and seed the relevant Custom Settings — before the unpackaged code will compile or its tests will run. A package version bump can force the entire dependent test suite to be re-run and, occasionally, repaired. The dependency graph is also a deployment graph.

At a glance:

Operation

What stands in the way

Uninstall a package

Every external reference to its namespace must be removed first; the platform blocks uninstall otherwise.

Upgrade a package

Changes to global signatures or referenced fields break dependent code or block the upgrade.

Change a shared object’s model

Silently breaks the other project that writes it via runtime name resolution.

Audit guest-user access

without sharing Apex hides the true surface from permission-set review.

Add a trigger on a package object

No ordering guarantee and a shared recursion budget with hidden managed logic.

 

Recommendations

None of these risks argue against cross-project integration; they argue for building the seams deliberately. The following measures keep the coupling manageable without giving up the value.

  • Wrap each dependency behind one façade. Route every reference to a package's objects or global methods through a single thin service class per dependency. When the package changes, the blast radius is one file. The codebase already leans this way with helpers like siteGuestUserUtilityHandler and the Weenvo service classes; make it the rule rather than the exception.
  • For true optionality, drop the hard reference. If a package must be genuinely removable, the EntityDefinition runtime check is not enough — use dynamic SOQL and Type.forName / the Callable interface so there is no compile-time name to block the uninstall.
  • Scope without sharing to the smallest possible surface. Prefer an inner privileged class or a single method over a class-level modifier, especially on anything a guest user can reach.
  • Give every shared object one owner of its invariants. Route all writers — UI and cross-project automation alike — through a single Apex service that enforces validation, rather than letting two triggers issue raw DML against the same object.
  • Make cross-project writes idempotent and logged. Store the foreign key (as the leave handler does with its Leave_Request__c lookup), guard against duplicates, and write failures to a log object instead of swallowing them in System.debug.
  • Document the dependency graph. You cannot safely remove or upgrade what you cannot see. A simple map of which project references which package object, field, and global method turns an archaeology project into a checklist.

 

Conclusion

Sharing one org between several products is a multiplier: the Leave Planner gets a proven approval engine and authentication flow, Capacity gets a live view of leave and Karma without anyone re-keying it, and each app keeps its own site and release cadence. The mechanisms that make this possible — custom triggers on package objects, calls into package globals, direct writes into a neighbouring project's objects, and runtime-guarded reads — are all sound when used deliberately.

The cost is coupling that does not appear on the architecture diagram. Namespaced references pin packages in place and gate their upgrades. A shared object with two writers and no owner of its invariants drifts the day someone renames a field. Guest access granted in without sharing Apex — often the only way past a Master-Detail relationship to a standard object — solves the access problem while hiding the security model from the people responsible for auditing it. The teams that stay out of trouble are the ones that treat every seam as a deliberate interface: one façade per dependency, one owner per shared object, the smallest possible privilege elevation, and a written-down map of what depends on what. The integration is the easy part; the discipline around it is what keeps the org upgradable.


Alexander Zherebilo
Certified Salesforce Developer
image
Expertise
Question to the expert
image

We have available resources to start working on your project within 5 business days

1 UX Designer

image

1 Admin

image

2 QA engineers

image

1 Consultant

image
Related Articles
All articles
image
Is Salesforce Winning the Public Sector Race?
An analysis of Salesforce's rapid expansion into the U.S. public sector, tracing its path from cautious early government licensing deals in the 2010s through the launch of Government Cloud in 2012, its pivotal role in COVID-19 vaccine rollouts, and its 2025–2026 push into military and intelligence work via Agentforce and Missionforce. The piece covers major 2026 contracts — including a $5.6 billion Army deal, a $1.6 billion VA agreement, and Pentagon Impact Level 5 authorization — alongside real-world case studies like California's REAL ID processing and the UK's NHS back-office operations. It also examines the structural obstacles still facing Salesforce and other vendors in government tech: legacy IT systems decades old, outdated federal procurement rules, budget constraints, and organizational caution around AI adoption, plus the competitive pressure from Palantir, Microsoft, and Oracle in the race for public sector AI spending.
28 August 2026
image
Why Your Salesforce Flows Are Agentforce's Biggest Problem
This article argues that the most underestimated risk in Agentforce deployments isn't data quality — it's the automation layer: years of overlapping Flows, Process Builder processes, Apex triggers, and managed package logic that no one has reviewed end-to-end. It explains why AI agents inherit automation complexity without the tribal knowledge human admins carry, why technical debt only becomes visible after an agent hits it in production, and why a clean demo is no indicator of production readiness. The article closes with a concrete, tool-by-tool inventory approach using Flow Trigger Explorer, Salesforce Optimizer, Setup Audit Trail, Apex Debug Logs, Agent Builder, and Health Check — scoped to the specific processes the agent will actually use rather than the whole org.
23 July 2026
image
GraphQL in LWC: Queries, Mutations, and When to Use Apex Instead
This article explains how GraphQL works inside Lightning Web Components, covering the query and mutation syntax developers need to fetch and modify Salesforce data efficiently. It walks through filtering, sorting, and pagination in queries, shows how to create, update, and delete records with mutations via executeMutation(), and details the lightning/graphql module setup with the graphql wire adapter. The piece also covers practical use cases (dashboards, record detail pages, mobile apps) and weighs GraphQL's benefits against its current limitations compared to Apex.
26 June 2026