27-Apr-2026 — From Mock to Production: a Pluggable Salesforce Member API


Background — what this API is for

The work in this article is the data layer for the Ramblers Email Project, a 2025 General Council motion (Motion 2025[7]) asking Head Office to deliver an email channel that lets Area and Group officers contact their own members. Ramblers has roughly 500 groups across the UK. Other than a small number of groups already running on NGX-Ramblers, none of them has an automated GDPR-compliant way to email its own membership. Groups have walks programmes, social events, AGM notices and committee changes to communicate; there has been no programmatic way to reach the people who have signed up to walk with them.

The technical block has always been the same shape: groups need a way to read their current member list and the consent flags those members have set. No such API exists today. Head Office's April 2026 AGM update (Item 9 — Open Motions Report) confirms a narrower path forward: two member-built systems — NGX-Ramblers and Charlie Bigley's MailMan — being prototyped as the interim route. Both depend on the API described in this article.

That is the wider project. The rest of this piece is about the Salesforce-shaped feed that makes the email work possible.

Status (updated 27 Apr 2026, post-meeting):

Wire format, OpenAPI and the public API are unchanged — NGX-Ramblers and MailMan are unaffected. The mock continues to run at salesforce-mock.ngx-ramblers.org.uk.

Companion piece to the existing Ramblers Salesforce Mock Server write-up.

Update (21 Jun 2026): since this article, Head Office confirmed they work in Python, Azure and Entra. The default production path is now a Python reference server on Azure Container Apps, interchangeable on the wire with everything below. The TypeScript server described here stays as a reference and fallback. Full write-up: A Python Reference Server.

If you're Ramblers Head Office: the production server is already standing at salesforce-server.ngx-ramblers.org.uk. The default is now the Python server (three-method checklist, ramblers-salesforce-server#3); the TypeScript route is the five-function checklist in ramblers-salesforce-server#1. Skip the architecture sections below if you just want to fill the mappers in.

The mock at salesforce-mock.ngx-ramblers.org.uk was built to unblock NGX Ramblers and Charlie Bigley's MailMan against the day-one #209 contract while Ramblers Head Office build the real thing on their own cadence. Structurally, that mock was already 90% of a production server. Replacing its data layer with a port-and-adapter interface meant the same code could run against synthetic data (mock mode) or a real Salesforce org (production mode), with no change to the wire format downstream consumers depend on.

This article describes how that split played out: a shared contract package, the mock server (refactored), and a production server Ramblers Head Office can take over and finish. The first version of the article (27 Apr) was a proposal; this version is a state of play.

Why this matters

Two deployment realities, one wire contract

From a consumer's perspective, the wire format is identical regardless of which server is on the other end of the cable.

flowchart TB
    NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
    MM@{ icon: "ngx:mailman", label: "MailMan", pos: "b", h: 48 }

    Wire(["Shared wire contract<br/>OpenAPI · Zod · TS types"])

    subgraph mock["Mock environment"]
        MOCKSRV@{ icon: "ngx:express", label: "Mock server", pos: "b", h: 48 }
        ATLAS@{ icon: "ngx:mongodb", label: "MongoDB Atlas<br/>synthetic data", pos: "b", h: 48 }
        MOCKSRV --> ATLAS
    end

    subgraph prod["Production environment"]
        PRODSRV@{ icon: "ngx:express", label: "Production server", pos: "b", h: 48 }
        SF["Salesforce org<br/>real member records"]
        PRODSRV --> SF
    end

    NGX --> Wire
    MM --> Wire
    Wire --> mock
    Wire --> prod

    style mock fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style prod fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style Wire fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:8,ry:8,color:#404143

The pattern

What we are describing is a textbook combination of two well-known patterns.

Hexagonal architecture (ports & adapters). A port is a TypeScript interface — a contract that says "anything wanting to provide member data to this server must offer these methods, with these inputs and outputs." It has no implementation; it is a shape. An adapter is a concrete class that implements the port: the actual code that knows how to fetch data from somewhere specific (a Mongo database, a Salesforce org, an in-memory test fixture). The HTTP and validation layers only ever talk to the port; they do not know or care which adapter is plugged in. Swapping the data source becomes a one-line change at app startup. Think of it like a USB port on a laptop: the port defines the shape and protocol; mice, keyboards and external drives are different adapters that all satisfy the same port.

Contract-first (or OpenAPI-first) development. The OpenAPI spec, the request/response Zod schemas, the error shape, and the TypeScript types all live in a single shared package that both servers depend on. The wire format cannot fall out of sync because there is only one place it lives — change the contract and both servers see the change at compile time.

Current architecture (28 Apr 2026)

flowchart TB
    NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
    MM@{ icon: "ngx:mailman", label: "MailMan", pos: "b", h: 48 }

    Contract@{ icon: "ngx:file", label: "@ramblers/sf-contract v0.3.0<br/>types · Zod · OpenAPI · errors · columns · MemberProvider", pos: "b", h: 64 }

    subgraph mock["Mock — salesforce-mock.ngx-ramblers.org.uk"]
        direction LR
        MOCKAPI["Express + Zod + auth"]
        MOCKADAPTER["MockMemberProvider"]
        MOCKDB@{ icon: "ngx:mongodb", label: "MongoDB Atlas<br/>Admin SPA · xlsx · synthetic", pos: "b", h: 48 }
        MOCKAPI --> MOCKADAPTER --> MOCKDB
    end

    subgraph prod["Production — salesforce-server.ngx-ramblers.org.uk"]
        direction LR
        PRODAPI["Express + Zod + bearer auth"]
        PRODADAPTER["SalesforceMemberProvider<br/>methods throw NotImplemented"]
        PRODSF["Salesforce org<br/>(Phase 4 — Head Office to wire)"]
        PRODAPI --> PRODADAPTER -.Phase 4.-> PRODSF
    end

    NGX --> Contract
    MM --> Contract
    Contract --> mock
    Contract --> prod
    mock ~~~ prod

    style mock fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style prod fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style Contract fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,rx:8,ry:8,color:#404143

Three repos, three deployments, one shared contract module versioned at v0.3.0. The MemberProvider interface, the wire-format types, the OpenAPI builder, the Zod request schemas, the error envelope and the 36 Insight Hub column definitions all live in @ramblers/sf-contract. Both servers pull a pinned tag and import from there — neither holds a copy.

The mock and the production server are visually and structurally peers. Wire format goes through the contract; whichever server a consumer points at, the bytes on the response are the same shape. Phase 4 is the dotted arrow: Head Office replaces the NotImplemented exceptions with real SOQL and field mappers.

Where the mock started (pre-Phase 1)

flowchart TB
    NGX@{ icon: "ngx:ramblers", label: "NGX Ramblers", pos: "b", h: 48 }
    MM@{ icon: "ngx:mailman", label: "MailMan", pos: "b", h: 48 }

    AUTH["Bearer + operator auth<br/>(src/auth)"]
    ADMIN["Admin SPA<br/>(src/admin)"]
    ROUTES["HTTP routes + validation<br/>(src/api)"]
    IMPORTER["xlsx import + synthetic generator<br/>(src/ingest)"]
    MONGOOSE["Mongoose models<br/>(src/db/models — directly imported by routes)"]
    ATLAS@{ icon: "ngx:mongodb", label: "MongoDB Atlas", pos: "b", h: 48 }

    NGX --> ROUTES
    MM --> ROUTES
    AUTH --> ROUTES
    ADMIN --> ROUTES
    ROUTES --> MONGOOSE
    IMPORTER --> MONGOOSE
    MONGOOSE --> ATLAS

    style MONGOOSE fill:#FBE3E3,stroke:#C46B6B,stroke-width:3px,color:#404143

The red box was the problem: route handlers import { Member } from "../db/models" and called Mongoose directly. There was no interface — to swap in Salesforce we would have had to fork the repo or rewrite the route layer in place. Phase 1 introduced that interface.

Where we wanted to be (the original target — now the implementation)

flowchart TB
    Contract@{ icon: "ngx:file", label: "@ramblers/sf-contract — OpenAPI · Zod · TS types · Errors", pos: "b", h: 64 }

    Core@{ icon: "ngx:express", label: "@ramblers/sf-server-core — Express · Routes · Validation", pos: "b", h: 64 }

    Port{{"MemberProvider port<br/>(listMembers · applyConsent · groupExists)"}}

    subgraph mock["ramblers-salesforce-mock"]
        MOCKADAPTER["MockMemberProvider"]
        MOCKMONGO@{ icon: "ngx:mongodb", label: "Mongo · Admin SPA · xlsx import · Synthetic", pos: "b", h: 48 }
        MOCKADAPTER --> MOCKMONGO
    end

    subgraph prod["ramblers-salesforce-server"]
        PRODADAPTER["SalesforceMemberProvider"]
        PRODSF["Salesforce org<br/>(jsforce · OAuth · Field mappers)"]
        PRODADAPTER --> PRODSF
    end

    Contract --> Core
    Core --> Port
    Port -.implemented by.-> MOCKADAPTER
    Port -.implemented by.-> PRODADAPTER

    style mock fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style prod fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143
    style Port fill:#FFF6D5,stroke:#E8B946,stroke-width:3px,color:#404143

Three packages, one contract, two interchangeable adapters. The hexagonal node in the middle is the interface — the MemberProvider that both adapters satisfy. Mock and production are visually and structurally peers: neither is privileged, and either one can be removed or replaced without touching the other or the contract above. The "server core" box collapsed in the actual implementation — the contract package now exposes the port directly, and each server keeps its own thin Express layer; one fewer publishable package without losing the property that matters (the port shape lives in one place).

What lives where

Concern Repo Lives at
OpenAPI builder (buildOpenApiDocument(opts)) contract packages/.../src/openapi.ts
Zod request schemas (listMembersQuerySchema, consentUpdateRequestSchema) contract src/schemas.ts
Wire-format TypeScript types (SalesforceMember, MemberListResponse, etc.) contract src/types.ts
Error code enum, envelope type, STATUS_BY_API_ERROR_CODE contract src/types.ts + src/errors.ts
36 Insight Hub column definitions + helpers contract src/columns.ts
MemberProvider interface + option/result types contract src/member-provider.ts
JSON Schema mirror of #209 contract schema/salesforce-api.schema.json
check:schema-sync script (verifies the JSON Schema matches #209) contract scripts/check-schema-sync.ts
Express app, route registration, asyncHandler mock + server each repo's src/api/
Validation middleware (uses Zod schemas from the contract) mock + server each repo's src/api/members.router.ts
Logging conventions (pino) mock + server each repo's src/logger.ts
Mongoose models mock src/db/models/
MockMemberProvider (Mongo-backed adapter) mock src/providers/mock-member-provider.ts
Synthetic data generator mock src/ingest/synthetic.ts
xlsx upload + download (round-trip) mock src/ingest/
Admin SPA mock src/admin/client/
Multi-tenant scoping (tenantCode on every model) mock mock-only concern
Operator-password auth + per-tenant bearer tokens mock src/auth/
Salesforce client wiring (jsforce) server src/salesforce/connection.ts
JWT-bearer connection setup server same file (Phase 4 fills buildJwtAssertion)
SalesforceMemberProvider adapter server src/providers/salesforce-member-provider.ts (Phase 4 fills the bodies)
SOQL query builders + salesforceToMember / memberToSalesforce mappers server same file (Phase 4)
Single-org tenant model + bearer-token auth server src/auth/bearer-auth.ts

The port — as built

The shape that ended up in @ramblers/sf-contract is slightly tighter than the original sketch — groupExists and groupName collapsed into a discriminated-union return type that the router translates into apiError envelopes. It is grounded in the two endpoints both servers serve: GET /api/groups/{groupCode}/members and POST /api/members/{membershipNumber}/consent.

// @ramblers/sf-contract — src/member-provider.ts

import type {
  ConsentUpdateRequest,
  ConsentUpdateResponse,
  MemberListResponse,
} from "./types.js";

export interface ListMembersOptions {
  groupCode: string;
  since?: Date;
  includeExpired?: boolean;
}

export interface ApplyConsentOptions {
  tenantCode: string;
  membershipNumber: string;
  request: ConsentUpdateRequest;
  appliedAt: Date;
}

export type ListMembersResult =
  | { kind: "ok"; response: MemberListResponse }
  | { kind: "groupNotFound" };

export type ApplyConsentResult =
  | { kind: "ok"; response: ConsentUpdateResponse }
  | { kind: "memberNotFound" };

export interface MemberProvider {
  listMembers(options: ListMembersOptions): Promise<ListMembersResult>;
  applyConsent(options: ApplyConsentOptions): Promise<ApplyConsentResult>;
}

The mock provider is what members.router.ts used to do inline, lifted behind the interface. The Salesforce provider replaces the Mongo queries with SOQL (Salesforce Object Query Language — Salesforce's SQL-like query language for fetching records, e.g. SELECT Id, FirstName FROM Contact WHERE...) calls and a field mapper:

// ramblers-salesforce-server — src/providers/salesforceMemberProvider.ts

export class SalesforceMemberProvider implements MemberProvider {
  constructor(private readonly conn: jsforce.Connection) {}

  async listMembers({ groupCode, since, includeExpired }: ListMembersOptions) {
    const soql = buildMemberSoql({ groupCode, since, includeExpired });
    const records = await this.conn.query<SfMemberRecord>(soql);
    return {
      groupCode,
      groupName: await this.groupName(groupCode),
      totalCount: records.totalSize,
      members: records.records.map(salesforceToMember),
    };
  }

  async applyConsent(opts: ApplyConsentOptions) {
    // composite request: update Contact + insert ConsentEvent__c
  }
}

Crucially: salesforceToMember() is the only function whose contents Ramblers Head Office truly need to write from scratch. Everything else is structure, types and plumbing we can hand to them already working.

Request flow — both servers, identical until the port

sequenceDiagram
    participant C as Consumer
    participant M as Middleware
    participant Z as Zod
    participant P as Port
    participant A as Adapter
    participant D as Store

    Note over C: NGX or MailMan
    Note over A: Mock or Salesforce
    Note over D: Mongo or Salesforce

    C->>M: GET /api/groups/EKWG/members?since=...
    M->>Z: validate query params
    Z-->>M: { since, includeExpired }
    M->>P: listMembers(opts)
    P->>A: delegate
    A->>D: .find() / SOQL
    D-->>A: raw records
    A-->>P: MemberListResponse
    P-->>M: response
    M-->>C: 200 application/json

The path through a mock deployment and the path through a production deployment are byte-identical from the consumer's perspective. The only difference is what is behind the port.

Phasing

flowchart LR
    P1["Phase 1 ✓<br/>In-repo refactor<br/>introduce port"] --> P2["Phase 2 ✓<br/>Extract contract<br/>into shared package"]
    P2 --> P3["Phase 3 ✓<br/>Stand up production<br/>repo + Salesforce stub"]
    P3 --> P4["Phase 4<br/>(next — Head Office)<br/>fill in mappers"]

    style P1 fill:#9BC8AB,stroke:#5E9170,stroke-width:2px,rx:12,ry:12,color:#FFFFFF
    style P2 fill:#9BC8AB,stroke:#5E9170,stroke-width:2px,rx:12,ry:12,color:#FFFFFF
    style P3 fill:#9BC8AB,stroke:#5E9170,stroke-width:2px,rx:12,ry:12,color:#FFFFFF
    style P4 fill:#FBE3F1,stroke:#C46B9C,stroke-width:2px,rx:12,ry:12,color:#404143

Phase 1 — Introduce the port (done; ramblers-salesforce-mock#5). MemberProvider defined under src/ports/. Mongo logic from members.router.ts lifted into src/providers/mock-member-provider.ts. Routes became provider-agnostic, receiving a MemberProvider from createApp(). Wire format unchanged.

Phase 2 — Extract the contract (done; ramblers-salesforce-mock#7). New repo nbarrett/ramblers-salesforce-contract, tagged v0.2.0. The mock declares it as a dependency at a pinned tag and consumes everything — types, schemas, OpenAPI builder, error envelope, columns, port — from one place. The repo commits its built dist/ so consumers can install from a git tag without running a build step themselves.

Phase 3 — Production server skeleton (done; ramblers-salesforce-contract#1). New repo nbarrett/ramblers-salesforce-server imports @ramblers/sf-contract@v0.2.0. SalesforceMemberProvider is wired through to the routes; every method throws NotImplemented with a structured hint about exactly which file and which function Head Office fills in next. jsforce + JWT-bearer connection plumbing is in place; the /healthz/salesforce smoke endpoint reports 503 not_configured until Head Office supply credentials. Live on Fly at salesforce-server.ngx-ramblers.org.uk.

Phase 4 — Hand-over (next; ramblers-salesforce-server#1). Ramblers Head Office inherit the production-server skeleton, the contract package, and a checklist of five concrete functions to fill: buildJwtAssertion, SalesforceMemberProvider.listMembers, SalesforceMemberProvider.applyConsent, salesforceToMember, memberToSalesforce. NGX-Ramblers remains available for contract questions and will offer paired sessions on the first endpoint.

What's pre-built versus what Head Office fill in

What we hand them, working:

What only Head Office can do:

Open questions and risks — what's resolved and what remains

Question Resolution
Multi-tenancy. Mock is multi-tenant by tenantCode. Real Salesforce is one org. ✓ Resolved. groupCode stays in the request URL path. The production server validates it against ALLOWED_GROUP_CODES from env config; the mock looks it up in Tenant. The port shape is identical for both.
Auth model. Mock uses operator passwords + per-tenant bearer tokens. Production needed something different. ✓ Resolved. Production uses long-lived bearer tokens whose sha256 hashes live in API_TOKEN_HASHES. Not Auth0, not OAuth on the consumer side. The org is the tenant boundary; tokens carry no per-tenant scope.
Admin UI scope. Synthetic data, xlsx imports, tenant management only make sense for the mock. ✓ Resolved. Admin SPA stayed in the mock repo only. The production server is API-only.
Wire format integrity. What stops the production server going off-spec once we hand it over? ✓ Resolved. Both servers depend on @ramblers/sf-contract at a pinned tag. The check:schema-sync script in the contract's CI verifies the JSON Schema in this repo still matches the body of ngx-ramblers#209 — schema changes have to come through the contract repo first.
Versioning strategy. Will breaking contract changes happen? ✓ Resolved. Semver on the contract package. Major version = breaking wire change. Both servers and both consumers pin a tag and update deliberately. v0.2.0 is the current shipping version.
Field-mapping size. Is it 36 lines or 300? Open. The 36 Insight Hub fields are exhaustively documented in src/columns.ts. What's open is Head Office's Salesforce-side inventory: object name (Contact? Member__c? person accounts?), custom field API names, picklist coercion rules. Tracked in the Phase 4 ticket.
Sandbox availability for the smoke test. Open. The production server boots without Salesforce credentials and /healthz/salesforce reports 503 not_configured. As soon as Head Office supply a connected app, SF_CLIENT_ID, SF_USERNAME and a JWT private key, the smoke endpoint goes live without a redeploy.
Repo ownership. Open. All three repos live under nbarrett/ for now. Transfer to a Ramblers Head Office org is part of the Phase 4 handover.

Status (as of 29 Apr 2026)

The original article advised holding at Phase 1 and treating Phases 2 to 4 as conditional on Ramblers Head Office buying in. After the 27 Apr morning meeting that condition was met immediately, so the work continued the same day.

Phase 1 merged in under an hour on 27 Apr. Phase 2 followed straight after: the contract is now its own repo at nbarrett/ramblers-salesforce-contract, versioned v0.2.0, consumed by the mock and the production server. Phase 3 went in on 28 Apr: the production server scaffolded with the full Express + jsforce skeleton, the SalesforceMemberProvider adapter wired through to the routes (every method throwing structured NotImplemented exceptions), CI and Fly deploy live at salesforce-server.ngx-ramblers.org.uk. Total work across all three phases: roughly a working day. Phase 4 is Head Office's seat: fill in five functions, all signed and documented, none of them more than a few dozen lines.

Three questions worth answering at the meeting

  1. Does anyone at Ramblers Head Office have a Salesforce-side member field inventory yet? Even a rough one. This is the single biggest unknown for hand-over effort.
  2. Who would own the production server once handed over? Internal Head Office developer, an external consultancy, or unstaffed? This changes how much hand-holding the skeleton needs.
  3. Is there an existing connected app / OAuth setup we can target a sandbox against? If yes, Phase 3 can include a real round-trip smoke test. If no, we stub harder and the smoke test waits.

Have a view on any of the above, or want to discuss before the work starts? Contact NGX-Ramblers and we will pick it up.

References