ADR 005 — Parallel focused services for vendor-owned writes (don't fold into existing tenant services)
Status: Accepted
Date: 2026-05-05
Context tags: identity, vendor, services, code-organization
Context
When polymorphic ownership landed on Identity::ServicePackage, we faced a service-layer decision. The existing Identity::Packages::SaveService is heavily tenant-coupled:
- Its constructor signature is
initialize(care_package_details:, facility_id:, tenant:, current_user:)— facility and tenant are required positional concerns. - It validates the facility belongs to the tenant.
- It looks up the package via
@tenant.service_packages.find_by(id:). - It handles
PackageComponentrows (clinic services bundled into the package) — vendor V1 packages have no components. - The legacy field-naming (
care_package_details,clinic_services,priceinstead oftotal_price) reflects historic CMS terminology that doesn't fit the vendor surface.
The original V1 plan called for a unified service: Identity::Packages::SaveService accepts owner: instead of tenant: + facility:, and tenant-owned vs vendor-owned callers pass different owners. That's the textbook "polymorphic dispatch" answer.
In practice, attempting it would mean:
- Adding
if owner.is_a?(Identity::Tenant) ... else ... endbranches throughout the existing service. - Either making facility lookups optional (and validating their absence for vendor) or splitting
validate_facilityinto two paths. - Either making component handling optional or producing dead branches for vendor callers.
- Reconciling the legacy
care_package_detailswrapper with a new vendor-side payload shape.
The end state would be a fatter service with broad surface area where most logic only applies to one branch. The two flows look similar from a distance — both create or update a ServicePackage — but the work each does is largely disjoint.
Decision
Build a parallel focused service for the vendor surface. Do not fold vendor-owned writes into Identity::Packages::SaveService.
The new service is Identity::Vendors::SavePackageService:
- Constructor:
initialize(vendor:, payload:). No tenant, no facility, no current_user (the caller already knows which vendor's packages it's writing). - No facility validation (vendor packages have none).
- No component handling (vendor packages have none in V1; will add when V2 introduces sub-services).
- Vendor-scoped uniqueness — different vendors may use the same package name, but a single vendor can't.
- Defaults to
package_type: 'wellness_program',currency: 'INR',is_active: true. - Modern field names directly (
total_price, not legacypricemapping).
The existing Identity::Packages::SaveService stays unchanged for tenant-owned writes. The CMS surface and the vendor surface call different services; the controller layer (Identity::ServicePackagesController for CMS, Identity::VendorPackagesController for vendor) routes appropriately.
Consequences
Positive
- Each service stays small and obvious. A future reader of
Vendors::SavePackageServicedoesn't have to skim past tenant/facility/component logic to understand the vendor flow. Same for the inverse. - No branchy code paths where mistakes hide. Every line in
SavePackageServiceruns every time; same for the vendor service. - The vendor surface can evolve independently. When V2 adds vendor-specific concerns (multi-vendor team accounts, recurring packages, custom pricing windows), they go in
Vendors::SavePackageServicewithout polluting tenant-side logic. - Easier testing. Each service's spec covers one shape end to end without combinatorial fixtures.
- Mirrors the controller split (
ServicePackagesControllerfor CMS,VendorPackagesControllerfor vendor) so the layer-by-layer story is consistent.
Negative
- Code duplication on the parts that ARE shared: name uniqueness, duration_unit validation, the
before_validation :calculate_expire_bycallback (which lives on the model, so technically not duplicated, but the wiring is). If a future change needs to update both, the developer has to remember both exist. - Onboarding new contributors: "where does package logic live?" is now a two-answer question. Mitigated by both services living adjacent in
domains/identity/services/and cross-referencing each other in their docstrings. - Risk of long-term divergence — drift between vendor and tenant package behavior could be surprising. We accept this risk for V1 because the two surfaces are intentionally different.
Convergence trigger
If we hit either of these, revisit:
- A shared concern emerges that both services need to call (e.g. a shared
PackageNameNormalizerorDurationCalculator). Extract the concern; keep the services parallel. - The vendor surface adds component support (V2). At that point, the shape difference between tenant and vendor packages narrows. Re-evaluate whether a unified
SaveServicewith explicit owner branching is now smaller than two parallel services.
Until then, parallel services win on clarity.
Related
Identity::Packages::SaveService— tenant-owned package writes (untouched)Identity::Vendors::SavePackageService— vendor-owned package writes (new in PR #1306)Identity::ServicePackagesController— CMS surfaceIdentity::VendorPackagesController— vendor surface- ADR 004 — Polymorphic-owner models skip
Shared::Concerns::TenantScoped - Plan:
docs/plans/2026-05-05-feat-vendor-self-serve-v1-plan.md— Phase 2