← All case studies

Case study · 07 / 11

One application, two backends, no component changes

A case study — SP.DMS document management system


Summary

| | |
|---|---|
| **Problem** | An enterprise document management system on SharePoint — lifecycle, approvals, versioning, audit, governance reporting — that could be demonstrated and developed before a tenant existed. |
| **Constraint** | Building against SharePoint from day one makes the app undemonstrable without a tenant and slow to develop. Building against a mock makes the SharePoint port a rewrite. |
| **Solution** | Dependency inversion: the UI depends only on service interfaces, resolved through a composition root, with a PnPjs/Graph backend and a localStorage backend behind the same contracts. |
| **Outcome** | Packaged as a deployable SPFx solution with both backends shipping in the same repository, and a documented seven-step rollout. |
| **Role** | Architecture, service layer and both implementations, all nine pages, setup wizard, documentation. |
| **Stack** | SPFx 1.22.1 · React 17 · TypeScript · PnPjs · Microsoft Graph · Power Automate (planned) · Microsoft Search / Purview (roadmap) |

1. The problem

Document management is the SharePoint use case with the widest gap between "we have a document library" and "we have a document management system." The library gives you storage and versioning. It does not give you document numbering, a lifecycle with approval routing, an approver queue scoped by role, a review-due calendar, a governance report, or an administration surface for taxonomy and routing rules.

The deliverable was all of that as a single web part: upload with metadata capture and auto-generated numbers, full-text search with facets, a role-aware approval queue with approve/return/reject and comments, document details with an approval timeline, version history, full audit trail, volume and governance reporting, and an administration area — plus a first-time setup wizard.

2. The constraint

An enterprise DMS is a long build, and it has to be shown to stakeholders repeatedly along the way. Two obvious approaches both fail:

Build directly against SharePoint. Every demo needs a tenant, a provisioned site, sample content and network access. Local development gets slow, and a sales conversation on a laptop is impossible.

Build against a mock, port later. The port becomes a rewrite, because mocks never have the same shape as the real thing — pagination, throttling, permissions, async patterns and error surfaces all differ.

3. The architecture

The answer is textbook dependency inversion, applied properly rather than gestured at.

web part (spfx/src/webparts/dmsApp) ── injects SharePoint context │ ▼ app (spfx/src/app) UI → service interfaces → ServiceProvider (composition root) │ ├─ services/local/* (localStorage) └─ services/sharepoint/* (PnPjs / Graph)

The UI imports interfaces only. Three of them: IConfigurationService, IUserService, IDocumentService. No page or component knows which implementation it is talking to.

One swap point. The SPFx web part builds the PnPjs and Graph clients from its context and calls setServices(createSharePointServices({ sp, graph })) in onInit(), before the app renders. If that call never happens — a standalone build, a demo, a local dev session — the provider falls back to the local services automatically.

Lifecycle logic is shared, not duplicated. documentLifecycle.ts holds the transition rules and is imported by both backends, so an approval behaves identically in localStorage and in SharePoint. This is the detail that makes the pattern honest: without it, the two implementations drift and the local build stops being a faithful preview.

The SharePoint modules are imported only by the host. They are never referenced from the local entry point, so they are not bundled into the local build at all.

4. The interesting decisions

4.1 Lossless storage plus mirrored refiners

The document model does not decompose cleanly into SharePoint columns — it has nested approval tasks, an audit array, tags, and a version history.

The solution stores the full document as JSON in a `DocJson` column, so the model round-trips without data loss, and mirrors the queryable fields into real columns — document number, status, type, department, confidentiality — so they can be filtered, indexed, and later used as Microsoft Search refiners.

This is a deliberate trade. Client-side filtering over the mirrored columns is fine at moderate scale; the documentation states plainly that for very large libraries list() should move to a Graph Search query, and notes that the refiner columns already exist for exactly that.

4.2 Roles from group membership, with a preview switcher

Four roles — Administrator, Approver, Contributor, Reader — resolved from SharePoint group membership through a configurable map, with site collection administrators automatically receiving Administrator.

The detail that made review sessions work: a top-bar user switcher that previews each role's experience. Stakeholders reviewing an approval workflow need to see it from the approver's side and the contributor's side within the same session, and provisioning four test accounts for a demo is friction that kills the conversation.

4.3 A setup wizard, not a configuration document

The app detects incomplete configuration and walks an administrator through organisation details, branding, taxonomy, document numbering, approval routes, roles, notifications, storage and initial content.

The alternative — a written configuration guide — puts a consultant between the customer and a working system. The wizard is the difference between a product and a project.

4.4 A rollout order that de-risks itself

The documentation does not say "switch to SharePoint." It gives a seven-step order chosen so each step proves something before the next depends on it:

  1. Provision the configuration list and document library.
  2. Configuration service — the smallest surface, and the one that proves auth and PnPjs wiring work at all.
  3. User service — real role-based visibility from group membership.
  4. Document read path — the app becomes read-live.
  5. Document write path — real file blobs and metadata.
  6. Lifecycle → Power Automate — submit/approve/return/publish become flow triggers.
  7. Search → Microsoft Search, then Purview labelling.

Steps 2 and 3 are deliberately first because they are cheap and they fail loudly if the tenant plumbing is wrong. Discovering an auth problem while wiring the document write path is a much worse afternoon.

4.5 Naming the platform features rather than rebuilding them

The architecture documentation maps each capability to the Microsoft 365 service that should own it eventually: Power Automate for approval routing, notifications and review reminders; Microsoft Search for tenant-wide relevance with security trimming; Microsoft Purview for mapping confidentiality levels to sensitivity and retention labels, driving DLP and records management; native version history rather than a custom versions table.

Knowing which parts of a DMS you should *not* build is most of the design work on this platform.

5. Outcome

A deployable SPFx package with nine pages — Dashboard, Search, Upload, My Documents, Approvals, Document Details, Reports, Admin, Setup — and both service backends in the same repository.

The documentation set is a consulting deliverable in its own right: a deployment guide, a first-time-setup guide, a customer-demo script, the service architecture, and a recommended SharePoint architecture and governance document.

6. What I'd take from this

Dependency inversion earns its keep when the second implementation is real. The pattern is often applied to an interface with exactly one implementation, where it costs indirection and buys nothing. Here the second implementation ships, is used daily in development and demos, and shares its lifecycle logic with the first — which is the case where the abstraction actually pays.

Shared logic is what keeps two backends honest. Two implementations of an interface will drift unless the behaviour that matters lives in one place both of them call.

A demo path is an architectural requirement. Treating "can this be shown on a laptop with no tenant" as a design constraint rather than a nice-to-have produced a better-separated codebase than the requirement alone would have.