Plentific · Data PM case study

Technical deep dive — for my understanding

How versioning actually works

Questions I had about how schema versioning works in practice, explained simply.

Question 1: How do we push data to their pipelines?

Not APIs. APIs are for real-time, one-record-at-a-time requests.

For bulk data, we write files and drop them somewhere:

Our system                              Their system
─────────────────                       ─────────────────
Plentific database                      Their S3 bucket
       │                                      │
       ▼                                      ▼
[Export job runs]                       [Their Airflow picks up files]
       │                                      │
       ▼                                      ▼
Creates Parquet files ───────────────▶  Loads into their Snowflake
       │
       └──── We PUSH files to their bucket (using S3 SDK)
             or they PULL from a bucket we host
    

The "push" is:

No API calls involved. Just file transfer.

Question 2: Do they need to test the new version before switching?

Yes, ideally. Here's the timeline:

DayWhat happens
Day 0 We announce v2026-10-01 is available. Both versions work: v2023-02-01 (old) and v2026-10-01 (new)
Day 1-30 Client's data engineer downloads sample from new version. Tests their pipeline against it. Finds what breaks, fixes it
Day 30 Client is ready. They tell us: "switch us to v2026-10-01". We update their config. Next export uses new version
Day 90 v2023-02-01 is retired. Anyone still on it gets cut off. (In practice, we'd chase them before this)

How do they test?

Question 3: Are we keeping two copies of the database?

No. There's only ONE database with the real data.

The "versions" are views or queries that sit on top of it.

The actual database (one copy)

-- This is the REAL table. Only one copy exists.
CREATE TABLE repairs (
    id UUID,
    client_id UUID,
    description TEXT,
    resident_name TEXT,     -- This is the current name
    status TEXT,
    created_at TIMESTAMP
);
    

The versioned views (no data duplication)

-- Version 2023-02-01 view (old name)
CREATE VIEW repairs_v2023_02_01 AS
SELECT 
    id,
    description,
    resident_name AS tenant_name,  -- Old name, mapped from new name
    status,
    created_at
FROM repairs
WHERE client_id = :current_client;

-- Version 2026-10-01 view (new name)
CREATE VIEW repairs_v2026_10_01 AS
SELECT 
    id,
    description,
    resident_name,  -- New name, direct
    status,
    created_at
FROM repairs
WHERE client_id = :current_client;
    
What happened here
  • The real table has resident_name
  • The old view renames it back to tenant_name for clients on old version
  • The new view uses resident_name directly
  • No data is copied. It's just a different "lens" on the same data

Question 4: What about adding a new column?

Same principle. Let's say we add uprn:

The actual database

ALTER TABLE repairs ADD COLUMN uprn TEXT;
-- Now the real table has: id, description, resident_name, status, uprn
    

The views

-- Version 2023-02-01 (old) - doesn't include uprn
CREATE VIEW repairs_v2023_02_01 AS
SELECT 
    id,
    description,
    resident_name AS tenant_name,
    status
    -- NO uprn here
FROM repairs;

-- Version 2026-10-01 (new) - includes uprn
CREATE VIEW repairs_v2026_10_01 AS
SELECT 
    id,
    description,
    resident_name,
    status,
    uprn  -- NEW column
FROM repairs;
    

Clients on old version never see uprn. It's filtered out by the view.

Question 5: What about new workflow states? (the hard one)

This is where it gets tricky. Let's use the real example:

Before: Booking status could be: scheduled, completed, cancelled

After: Booking status can be: scheduled, arrived, in_progress, attended, not_attended, no_access, completed, cancelled

The data itself now has the new statuses. We can't "hide" them like we hide a column.

Option A: Map new statuses back to old ones

-- Version 2023-02-01 view
CREATE VIEW bookings_v2023_02_01 AS
SELECT 
    id,
    CASE 
        WHEN status IN ('arrived', 'in_progress') THEN 'scheduled'
        WHEN status IN ('attended', 'not_attended', 'no_access') THEN 'completed'
        ELSE status
    END AS status
FROM bookings;
    

This "collapses" the new statuses back into the old ones. Old clients see what they expect.

Problem with Option A

You're losing information. not_attended and attended both become completed. That might be wrong for the client's use case.

Option B: Don't map, just warn

-- Version 2023-02-01 view
-- Status column unchanged. New values will appear.
-- Changelog says: "New status values added. Your pipeline must handle unknown values."
    
Problem with Option B

This breaks clients who have hard-coded logic like IF status = 'completed' THEN...

Option C: Call it a breaking change, force migration

If the status change is fundamental, you declare it a breaking change:

The real trade-off

There's no perfect answer. You pick based on:

  • How fundamental is the change?
  • How many clients would break?
  • Is the mapping semantically correct or misleading?

Question 6: How does "one schema per version" actually work in real life?

Here's the complete real-life setup:

1. The real database (one copy, always current)

┌─────────────────────────────────────┐
│  repairs (real table)               │
│  ─────────────────────              │
│  id, description, resident_name,    │
│  status, uprn, created_at           │
└─────────────────────────────────────┘
    

2. The version layer (views, no data copied)

┌─────────────────────────────────────┐
│  repairs_v2023_02_01 (view)         │
│  ─────────────────────              │
│  SELECT id, description,            │
│         resident_name AS tenant_name│
│  FROM repairs                       │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│  repairs_v2026_10_01 (view)         │
│  ─────────────────────              │
│  SELECT id, description,            │
│         resident_name, uprn         │
│  FROM repairs                       │
└─────────────────────────────────────┘
    

3. The export job (reads from views)

export_config = {
    "client": "NHG",
    "version": "v2023-02-01",   # <-- This client is on old version
    "destination": "s3://nhg-bucket/..."
}

# Export job does:
SELECT * FROM repairs_v2023_02_01 WHERE client_id = 'nhg'
# Writes result to Parquet
# Uploads to their bucket
    

4. The client config (just one field different)

ClientVersionDestination
NHGv2023-02-01s3://nhg-bucket/
Peabodyv2026-10-01s3://peabody-bucket/
Southernv2023-02-01azure://southern-blob/

When NHG is ready to upgrade

  1. They test with v2026-10-01 data
  2. They tell us "ready"
  3. We change their config row from v2023-02-01 to v2026-10-01
  4. Next export uses the new view
  5. Done

The key insight

Views are cheap

They're just SQL queries with a name. They don't copy data.

Maintaining two versions means: writing two SQL queries that read the same table differently.

The cost is not storage. The cost is:

Why scope discipline matters

The more columns and tables you expose, the more views you have to maintain when things change.

Every field you promise is a field you must protect forever. That's why saying "no" to fields is how the product stays maintainable.