How do you share business logic safely?
“Safely” is doing real work in this question — sharing business logic is easy; sharing it without creating hidden coupling, silent breakage, or untestable spaghetti is the actual problem.
What “unsafe” sharing looks like, so the target is clear
- Feature A directly imports Feature B’s internal type to reuse a calculation — now A is coupled to B’s implementation.
- A shared utility class accumulates unrelated logic because it’s the easiest place to add “just one more thing.”
- Business rules duplicated slightly differently in two places because sharing felt like too much ceremony.
- A shared module change breaks three unrelated features silently, discovered only in QA or production.
1. Extract to a dedicated shared module with a narrow, intentional public surface
Shared business logic gets its own module, with internal/public access control used deliberately so only the intended entry points are visible:
// PricingKit module
public struct DiscountCalculator {
public init(rules: [DiscountRule]) { ... }
public func applicableDiscount(for cart: Cart, user: User) -> Discount { ... }
}
Consumers depend on PricingKit’s narrow public API, not on internals — this is what lets PricingKit be refactored freely as long as that public contract’s behavior is preserved.
2. Depend on protocols, not concrete types, at the sharing boundary
public protocol DiscountCalculating {
func applicableDiscount(for cart: Cart, user: User) -> Discount
}
This buys you swappable implementations and a contract surface small enough that changes to it are a deliberate, visible event.
3. Version and gate breaking changes to the shared contract deliberately
- Deprecate before removing — mark old methods
@available(*, deprecated, message: "use X instead"). - Additive changes preferred over modifying existing signatures.
- CODEOWNERS-style required review on the shared module’s public interface.
4. Test the shared logic exhaustively and independently of any consumer
Because multiple features/teams rely on shared business logic, its test suite carries more weight — a bug here has multiplied blast radius. High unit test coverage on the shared logic itself, tested in complete isolation; contract tests if there’s risk of multiple implementations diverging; consumers write their own tests against the protocol so their tests don’t break every time the shared implementation’s internals change.
5. Keep shared logic free of anything platform/UI-specific
The moment shared business logic starts importing UIKit/SwiftUI or knowing about a specific screen’s presentation needs, it stops being safely shareable. This is the same discipline as Clean Architecture’s Entities/Use Case layers: pure domain logic, Foundation-level types only, no view-layer concerns.
6. Avoid “shared” becoming a dumping ground — apply the same discipline as anti-Massive-ViewModel
A Shared/Common/Utils module is exactly where undisciplined logic accumulates. The guardrail: a piece of logic belongs in a shared module only if (a) it’s genuinely used by 2+ consumers today, not speculatively, and (b) it has a coherent, nameable responsibility (DiscountCalculator, not Helpers). “Might be useful elsewhere someday” is a weak justification for extracting something prematurely.
7. For genuinely cross-platform business logic (iOS + Android, or iOS + backend), consider where the boundary actually should be
Some teams push shared business logic into Kotlin Multiplatform, a shared C++/Rust core, or simply duplicate the logic per platform with strong contract tests ensuring behavioral parity. This is a bigger architectural bet — cross-platform shared logic solves duplication but introduces real tooling/build complexity. The safer default for most teams is: share within a platform aggressively, and be much more conservative about cross-platform code sharing, reserving it for logic that’s both complex and genuinely identical in behavior across platforms.
Putting it together — the actual discipline
Shared business logic is safe when it’s exposed through a small, protocol-defined, well-tested contract, and dangerous when consumers can reach past that contract into implementation details.
One-liner if pressed: “Sharing business logic safely means sharing a contract, not an implementation — extract it into a module with a narrow protocol-defined public surface, keep it free of UI/platform concerns, test it exhaustively in isolation, and treat changes to that public contract with the same deprecate-before-break discipline as any public API — the unsafe version is when consumers can reach past the contract into internals, or when ‘shared’ logic gets duplicated slightly differently because sharing felt like more ceremony than it was worth.”