Kulcare Docs

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 PackageComponent rows (clinic services bundled into the package) — vendor V1 packages have no components.
  • The legacy field-naming (care_package_details, clinic_services, price instead of total_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:

  1. Adding if owner.is_a?(Identity::Tenant) ... else ... end branches throughout the existing service.
  2. Either making facility lookups optional (and validating their absence for vendor) or splitting validate_facility into two paths.
  3. Either making component handling optional or producing dead branches for vendor callers.
  4. Reconciling the legacy care_package_details wrapper 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 legacy price mapping).

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::SavePackageService doesn'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 SavePackageService runs 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::SavePackageService without polluting tenant-side logic.
  • Easier testing. Each service's spec covers one shape end to end without combinatorial fixtures.
  • Mirrors the controller split (ServicePackagesController for CMS, VendorPackagesController for 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_by callback (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:

  1. A shared concern emerges that both services need to call (e.g. a shared PackageNameNormalizer or DurationCalculator). Extract the concern; keep the services parallel.
  2. The vendor surface adds component support (V2). At that point, the shape difference between tenant and vendor packages narrows. Re-evaluate whether a unified SaveService with explicit owner branching is now smaller than two parallel services.

Until then, parallel services win on clarity.

  • Identity::Packages::SaveService — tenant-owned package writes (untouched)
  • Identity::Vendors::SavePackageService — vendor-owned package writes (new in PR #1306)
  • Identity::ServicePackagesController — CMS surface
  • Identity::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

On this page