Project 02 · Data Engineering

Paginated OData Ingestion:SAP → Azure SQL

A metadata-driven Azure Data Factory framework ingesting SAP through the OData connector. Because the OData service caps rows per response, the child pipeline wraps the copy in an Until loop that pages through the delta set — committing each page as it lands — while a watermark keeps the whole run incremental. Medallion layers in ADLS Gen2, secrets in Key Vault, released via Azure DevOps.

Pagination strategy
Until loopPagination strategy
Refresh cadence
DailyRefresh cadence
New pipelines per entity
0New pipelines per entity
Default load pattern
IncrementalDefault load pattern
  • Azure Data Factory
  • ADLS Gen2
  • Azure SQL Database
  • Azure Key Vault
  • Azure DevOps CI/CD
  • Medallion Architecture
  • SAP OData
  • OData Pagination

Select any component to see how it works.

Source

SAP via OData service

Auth: OData service credentials resolved at runtime from Key Vault — never stored in the pipeline.

Control Plane

Metadata decides everything

Ingestion

SAP OData child pipeline

Medallion Layers

ADLS Gen2

Serving

Consumption layer

Outcome: ERP data queryable in SQL — no OData round-trips, no manual extracts.

Cross-cutting platform concerns

Architecture overview · Project 02 · Select any block for detail

Read every component in full

Component reference

Every component in the architecture above, in detail — each with a plain-language summary and the technical implementation.

SourceSAPERP system of record, surfaced through OData services rather than direct database access.

In plain terms

SAP holds the operational heart of the business — orders, stock, suppliers, financials. It is also notoriously difficult to get data out of.

Why it's hard

  • You generally can't query the database directly — access goes through a controlled interface
  • That interface limits how many records it will return in one go
  • The tables are enormous, so "just export it" is not an option
  • Records are updated constantly, so a snapshot goes stale immediately
These constraints are the reason this project looks the way it does. Nearly every design decision here exists to work within SAP's limits rather than fight them.

Technical detail

What gets pulled

  • Business entities exposed as OData services — sales orders, materials, deliveries, finance documents
  • Each entity is one entry in the metadata config, not one new pipeline

Why SAP is a demanding source

  • No direct DB access — extraction goes through the OData service layer
  • Response size caps — the service will not return an unbounded result set
  • Large entities — full extracts are impractical at volume
  • Constant change — records are updated in place, so the target must be merged
SourceOData Linked ServiceThe ADF OData connection — configured once, reused by every entity in the metadata.

In plain terms

Rather than reaching into SAP's database, the system talks to SAP through its official data service — the supported, sanctioned route.

Why that choice matters

  • It's the route SAP supports, so upgrades don't quietly break it
  • Security and permissions are enforced by SAP itself, not worked around
  • The same connection serves every type of record — set up once, reused forever
  • Login details come from a secure vault at the moment they're needed
Going around a system's official interface is faster to build and a liability forever. This took the supported route.

Technical detail

How the connection is defined

  • A single ADF OData linked service pointed at the SAP gateway
  • Credentials are Key Vault references, not stored values
  • One dataset, fully parameterised — entity path passed in at runtime

Query construction

  • The request is assembled from metadata: entity path, $select for required fields, $filter for the watermark, and the paging parameters
  • Nothing entity-specific is baked into the connection or dataset
Using the OData service layer rather than direct table reads keeps the integration on a supported path and inside SAP's own authorisation model.
Control PlaneMetadata ConfigurationStored in Blob / ADLS Gen2. Every pipeline decision is read from here at runtime.

In plain terms

Instead of writing a separate program for every type of record, the system reads a simple settings file that tells it what to do.

What the settings file says

  • Which record type to copy from SAP
  • Where to put it in the destination database
  • Everything, or just the changes?
  • How many records to request at a time — the batch size
  • Which field goes where between the two systems
  • When it last ran, so it knows where to pick up from
Why this is the whole point: when the business asks for a new type of SAP data, nobody writes new software. Someone adds a few lines to a settings file. What would normally be a multi-week project becomes an afternoon.

Technical detail

What the config carries

  • entity_path — the OData entity set to pull
  • schema_name / table_name — target schema and table in SQL
  • load_typefull or incremental
  • page_size — rows per OData request, tuned per entity
  • mapping_file — pointer to the column mapping JSON
  • watermark column & last-run value — the incremental boundary

Shape

{
  "entity_path": "A_SalesOrder",
  "schema_name": "sap",
  "table_name": "SalesOrder",
  "load_type": "incremental",
  "watermark_column": "LastChangeDateTime",
  "page_size": 5000,
  "mapping_file": "maps/salesorder.json"
}
The payoff: onboarding a new entity is a config drop and a pull request — no pipeline development, no regression testing of existing feeds.
Control PlaneMaster PipelineThe single entry point — reads metadata, decides what runs, drives the worker.

In plain terms

Think of it as a shift supervisor. It reads the instruction sheet, works out what's due today, and hands each item to the worker one at a time.

What it does each run

  • Reads the settings file
  • Works out which record types are due
  • Hands each one to the worker, with its instructions attached
  • Keeps going if one item fails — the rest of the day's work still completes
Because the supervisor's logic lives in one place, a change to how everything runs is made once, not repeated dozens of times.

Technical detail

Execution flow

  • Lookup — read the metadata config from Blob storage
  • Filter — keep only entities marked active and due to run
  • ForEach — iterate that list
  • Execute Pipeline — invoke the child, passing every value as a parameter

Why a master/child split

  • Orchestration logic lives in one place and changes once
  • The child pipeline stays small, testable and independently deployable
  • A failure on one entity doesn't stop the rest of the run
  • Parallelism and batch size are controlled centrally at the ForEach
Control PlaneForEach → Execute PipelineThe hand-off — one iteration per SAP entity, each fully parameterised.

In plain terms

The list from the instruction sheet gets worked through one item at a time — orders, then materials, then deliveries — each handed to the same worker with different instructions.

Why this is deliberate

  • Several items can be handled at once to save time, but not so many that SAP starts refusing requests
  • If one record type has a problem, it's recorded and the rest carry on
  • Adding an item to the list needs no change here at all
Restraint matters more than usual here — SAP is a live operational system. Pulling too aggressively can slow down the people actually using it.

Technical detail

How it works

  • ForEach iterates the entity list returned by the metadata lookup
  • Each iteration calls the same child pipeline with a different parameter set
  • Batch count is kept deliberately conservative — the OData gateway is a shared, live resource
  • Failures are captured per iteration, so one bad entity doesn't fail the run

Parameters passed down

entity_pathschema_nametable_name load_typepage_sizemapping_filewatermark_column
IngestionSAP OData Child PipelineOne parameterised pipeline serving every SAP entity in the config.

In plain terms

This is the part that does the actual copying. Crucially, there is only one of it — the same worker handles orders, materials and deliveries, just with different instructions each time.

What it does, in order

  • Checks when it last ran for this record type
  • Asks SAP for what's changed since then — but only a batch at a time
  • Repeats that request until SAP has nothing left to give
  • Updates the database — changing existing records rather than duplicating them
  • Records the new timestamp, but only if every batch succeeded
Why that last point matters: if the run fails on batch seven of twelve, the system doesn't pretend it finished. The next run repeats the whole window, and nothing is silently lost.

Technical detail

Inside the pipeline

  • Get watermark — read the last successful load boundary
  • Initialise paging state — offset / skip token set to zero
  • Until loop — request one page, land it, advance the offset, repeat
  • Load to SQL — staged, then merged into the target table
  • Update watermark — committed only after the final page completes

Design principles

  • Idempotent — a re-run produces the same result, no duplicate rows
  • Fail-safe watermark — a mid-loop failure means the next run retries the full window rather than resuming into a gap
  • Zero-code onboarding — new entity, new config entry, done
IngestionIncremental Load StrategyWatermark-bounded delta — the filter travels with every page request.

In plain terms

SAP tables are huge — often millions of rows. Copying all of them every night would be slow, expensive, and would put real strain on a system people are actively working in.

How it knows what's new

  • SAP stamps records with when they were last changed
  • The system remembers when it last ran
  • It asks only for records touched since that moment
  • Changed records are updated in place, not duplicated
This works together with batching: the day's changes are identified first, then fetched a batch at a time. One decision narrows the data, the other makes it retrievable.

Technical detail

How it works

  • Each entity declares a watermark column (e.g. LastChangeDateTime)
  • The pipeline reads the last successful watermark before the pull
  • An OData $filter bounds the request to rows changed after it
  • That filter is re-applied on every page, so the loop pages through the delta only, not the full entity
  • Rows are merged into the target on the business key
  • The watermark advances only after the last page commits

Full load still available

  • load_type: "full" drops the filter and pages through the entire entity
  • Used for initial loads, small reference entities, and recovery
Filter and pagination compose: the watermark decides what is in scope, pagination decides how it's carried across.
IngestionOData Pagination LoopThe defining mechanism — a bounded loop that pages through the delta set.

In plain terms

SAP's data service refuses to hand over everything at once. Ask for 200,000 records and you won't get them — you'll get a capped response, or an error, or a timeout.

What most people do wrong

  • Ask for everything, and the request fails outright — obvious, at least
  • Or worse: the request quietly returns only the first batch, the pipeline reports success, and the rest of the data silently never arrives

What this system does instead

  • Asks for a manageable batch — say the first 5,000 changed records
  • Saves that batch before asking for anything else
  • Asks for the next batch, and the next, keeping its place each time
  • Stops automatically when a batch comes back short — that's how it knows it's reached the end
Think of it like moving house with a small car. You can't take everything in one trip, so you make repeated trips, keep track of what's already moved, and stop when the house is empty. The system does exactly that, unsupervised, every night.

Why it's built to stop on its own

  • Nobody knows in advance how many records changed today — it might be 50, it might be 500,000
  • So the system doesn't guess a number of trips. It keeps going until the source says there's nothing left
  • A hard ceiling on trips is still enforced, so a fault can never leave it looping forever

Technical detail

The constraint

  • SAP OData services cap rows per response — via service-side page size or an explicit $top
  • An unbounded request either errors, times out, or silently truncates — the last being the dangerous case, since the pipeline reports success on partial data

The loop

  • Set variable — initialise skip = 0, hasMore = true
  • Until hasMore == false:
    • Copy activity requests $top={page_size}&$skip={skip} with the watermark $filter applied
    • Page is landed to Bronze and merged onward before the next request
    • skip += page_size
    • hasMore = (rowsReturned == page_size) — a short page means the end
  • Safety ceiling — a max-iteration guard so a misbehaving source can't loop indefinitely

Why the termination condition is a short page

  • The delta size is unknown at design time — a fixed iteration count would either truncate or waste calls
  • A page returning fewer rows than requested is the reliable end-of-set signal
  • Where the service returns @odata.nextLink, following it until absent is equivalent and preferable — it removes offset arithmetic entirely

The trap worth knowing about

  • $skip-based paging assumes a stable ordering. If the underlying set shifts between calls, rows can be skipped or repeated
  • Mitigated by ordering on a stable key and by merging on the business key — a repeated row updates rather than duplicates
  • The bounded watermark window also limits exposure, since the delta set is small and short-lived compared to the full entity
Page size is per-entity metadata, not a global constant — wide entities need smaller pages than narrow ones, and the right value is found by testing rather than assumed.
IngestionDynamic Column MappingSource-to-target mapping lives in a config file, not inside the pipeline.

In plain terms

SAP field names are famously cryptic — a column called MATNR is a material number, and nothing about the name says so. This is the translation list that makes the data readable.

What it handles

  • Turning SAP's technical field names into names people recognise
  • Bringing across only the fields the business needs, not all several hundred
  • Making sure a date arrives as a date, and a number as a number
Selecting only the needed fields does double duty here: it makes the data readable and it shrinks every request, so each batch carries more rows.

Technical detail

What it handles

  • SAP technical field → business-readable target column
  • Data type alignment between OData and SQL
  • $select projection — only required fields cross the wire

Why externalise it

  • A source schema change is a config edit, not a pipeline redeploy
  • Mapping is reviewable in Git alongside the rest of the metadata
  • The same copy activity serves every entity because mapping is injected at runtime
Field selection is also a performance lever: narrowing the projection reduces payload per page, which raises the usable page size.
Medallion · Layer 1Bronze — Raw LandingData lands exactly as the OData service returned it, in ADLS Gen2.

In plain terms

The first thing the system does is take a photograph of the data exactly as SAP handed it over — before anyone touches it.

Why keep an untouched copy

  • If a mistake is found later in the clean-up rules, everything can be rebuilt from here — without going back to SAP again
  • That last point matters more here than usual: re-pulling from SAP is slow and puts load on a live business system
  • It's a record of exactly what SAP said on any given day, which matters if numbers are ever questioned
Each batch is saved as it arrives, so even a run that fails halfway leaves behind everything it had already collected.

Technical detail

Rules of the layer

  • No transformation. Raw fidelity is the entire point
  • Partitioned by load date, with each page landed as it arrives
  • Append-only — history preserved even when the source overwrites it

Why it earns its place

  • Downstream fixes are made by rebuilding Silver and Gold without re-paging the OData service — a meaningful saving when a full delta is dozens of round-trips
  • Provides an audit trail of exactly what SAP returned, page by page
Medallion · Layer 2Silver — Cleansed & ConformedRaw pages become a single trustworthy, queryable set.

In plain terms

Real data is messy, and SAP's is messier than most — codes instead of names, padded values, dates in formats nothing else understands.

What gets fixed

  • Duplicates removed — including any record that appeared in two batches
  • Formats standardised — dates look like dates, numbers behave like numbers
  • SAP quirks tidied — leading zeros, padded codes, blank-versus-missing
  • Problem records flagged, not quietly deleted — so someone can look at them
De-duplication does real work here. Because the data arrives in batches, a record that shifted position between requests could turn up twice. This stage guarantees one row per record regardless.

Technical detail

What happens here

  • Type casting — OData strings become dates, decimals, booleans
  • De-duplication — one row per business key, latest version wins; also the backstop against page-boundary repeats
  • Standardisation — leading-zero handling, code normalisation, null semantics
  • Validation — bad records flagged rather than silently dropped
Deduplication on the business key is what makes $skip-based paging safe in practice — a row returned twice updates rather than duplicates.
Medallion · Layer 3Gold — Business ReadyCurated tables shaped around business questions rather than source structures.

In plain terms

Clean data still isn't useful data. This stage reshapes it around the questions people actually ask.

What happens here

  • Data is arranged by how the business thinks — orders, customers, products, time periods
  • Calculations everyone relies on are worked out once, in one place
  • Structured so reports open quickly instead of grinding
The quiet benefit: because definitions live in one place, two reports can't disagree about what "open order" means. Anyone who has sat in a meeting where two teams present different numbers for the same thing knows why that's worth having.

Technical detail

What lives here

  • Conformed dimensions and fact tables
  • Business logic and derived measures applied consistently, once
  • Models shaped for reporting performance, not for SAP's internal structures

The contract

  • Business users and reports consume Gold — never Bronze or Silver
  • Definitions live in one place, so two reports can't disagree on a metric
ServingAzure SQL DatabaseThe consumption target — the warehouse the business queries.

In plain terms

The destination. Reporting tools and analysts read from here — not from SAP, which keeps the load off a live operational system.

How updates are handled safely

  • New data is prepared off to the side first, then swapped in
  • Existing records are updated, new ones added — nothing is wiped and rebuilt
  • The database stays available the whole time — nobody sees it go down mid-refresh
  • If an update fails, the existing data is left untouched rather than left half-finished
That last one is the difference between "the report is a day old" and "the report is wrong and nobody noticed."

Technical detail

Load pattern

  • Pages are written to a staging table first
  • A MERGE upserts on the business key — updates existing rows, inserts new ones
  • This is also what makes repeated rows across page boundaries harmless
  • Wrapped in a transaction — a failed load leaves the target untouched

Access

  • Connection strings resolved from Key Vault at runtime
ServingDownstream ConsumersWhat the pipeline ultimately exists to serve.

In plain terms

None of the engineering matters if nobody can use the result. This is the part the business actually touches.

What changes day to day

  • Dashboards are current every morning, with nobody preparing them
  • Questions get answered in seconds instead of raising a request with the SAP team
  • Reporting queries run against the warehouse, so they never slow SAP down
  • No SAP licence needed just to look at the numbers
The measure of success here is boring: the data is simply correct and current every morning, and nobody has to think about it.

Technical detail

Who reads this data

  • Reporting and BI dashboards
  • Business applications needing a consolidated operational view
  • Analysts running ad-hoc SQL

The business outcome

  • ERP data is queryable in SQL instead of via SAP extracts and change requests
  • Analytical load is moved off the live ERP entirely
  • Refreshed daily without anyone touching a pipeline
PlatformAzure Key VaultEvery secret lives here and is fetched at runtime.

In plain terms

The system needs logins for SAP and the database. Those are never written into the system itself — they're kept in a locked vault and requested at the moment they're needed.

Why this is non-negotiable

  • No password ever appears in the project files, so a copy of the code is worthless to an attacker
  • This matters especially with SAP — those credentials often carry access to genuinely sensitive operational data
  • Changing a password is done in the vault — nothing needs rebuilding
  • Access is granted to the system itself, not to a person, and every access is logged
Hard-coded passwords are one of the most common causes of real-world breaches. This design removes the possibility rather than relying on people to be careful.

Technical detail

What's stored

  • SAP OData service credentials
  • SQL Database connection strings
  • Storage account keys for the ADLS Gen2 landing zone

How it's used

  • Linked services reference Key Vault secrets rather than storing values
  • Access via managed identity — no credentials in code or config
  • Secret rotation happens in the vault with no pipeline change
  • Nothing sensitive lands in Git or an ARM template
PlatformAzure DevOps CI/CDPipelines are versioned, reviewed and promoted like application code.

In plain terms

Nobody edits the live system directly. Changes are made in a practice copy first, tested, reviewed, and only then promoted.

The route every change takes

  • Built and tried in a development copy
  • Checked in a test copy using realistic data
  • Reviewed by someone else before it can go further
  • Released to live only after passing all of that

What this buys you

  • A full history of every change and who made it
  • If something goes wrong, it can be reversed in one step
  • The live system can't be broken by an accidental edit

Technical detail

The flow

  • ADF is Git-integrated — every change is a branch and a pull request
  • Publishing generates ARM templates from the collaboration branch
  • Release pipeline deploys through Dev → Test → Prod
  • Environment-specific values — including SAP gateway endpoints — injected via parameter overrides per stage

What it prevents

  • No manual changes in the production Data Factory
  • Full change history and a one-step rollback path
  • Peer review before anything reaches production
PlatformMonitoring, Logging & AuditPer-page observability — the only way to debug a loop you can't watch.

In plain terms

An automated system that fails silently is worse than no system at all — people keep trusting numbers that stopped updating weeks ago.

What gets recorded every run

  • When it started, when it finished, how long it took
  • How many batches were fetched, and how many records in each
  • Whether it reached the end properly — or stopped early
  • If it failed, exactly which batch it failed on

Why the batch detail matters

  • "The load failed" is not useful. "It failed on batch 7 of an expected 12" tells you immediately what happened and what to do
  • Batch counts also reveal silent truncation — if a run that normally takes 12 batches suddenly takes 1, something is wrong even though nothing errored

Technical detail

Captured per run

  • Pipeline run ID, entity, start and end time
  • Iteration count and rows per page — not just a run-level total
  • Termination reason — short page, nextLink absent, or safety ceiling hit
  • Watermark value before and after

Why per-page logging is essential here

  • A run-level row count can't distinguish "a quiet day" from "the loop exited after one page"
  • Iteration counts make silent truncation visible — the failure mode that produces confidently wrong data
  • A mid-loop failure is diagnosable to the exact page and offset

Operational handling

  • Retry policy on transient gateway and network failures — common with OData under load
  • Alerting on the safety ceiling being reached, which indicates a source or logic problem
  • Per-entity isolation — one failing feed doesn't block the run
Design RationaleWhy This DesignWorking within the OData constraint rather than against it.

In plain terms

Two decisions carry this project, and they solve different problems.

1 — Only fetch what changed

  • SAP tables run to millions of rows. Copying everything nightly is slow, expensive, and strains a live system
  • Fetching only the day's changes shrinks the job to a fraction of the size

2 — Fetch it a batch at a time

  • Even the day's changes can exceed what SAP will hand over in one request
  • So the system asks repeatedly until it has everything — and knows how to tell when it's finished

And underneath both: a settings file, not code

  • New record type → update the settings. No new software
  • An improvement made once applies everywhere immediately
  • Each addition costs a fraction of the first

The honest trade-off

  • It takes longer to build the first one this way
  • The batching loop is harder to reason about than a single copy step — which is exactly why the logging is as detailed as it is
In one line: SAP's limits weren't worked around, they were designed for — so the system stays reliable whether today brought fifty changes or half a million.

Technical detail

The constraint that shapes everything

  • SAP OData caps rows per response. Any design ignoring this either fails loudly or — far worse — truncates silently and reports success
  • Silent truncation is the dangerous failure mode: the pipeline goes green, the data is incomplete, and nobody finds out until someone questions a number

The two composed mechanisms

  • Watermark filter bounds what is in scope — the delta, not the entity
  • Pagination loop governs how that scope is carried across — bounded requests, landed incrementally
  • Neither alone is sufficient: a filter without paging still overruns the cap on a heavy day; paging without a filter walks the entire entity nightly

Why dynamic termination rather than a fixed count

  • Delta volume is unknowable at design time — it varies by orders of magnitude day to day
  • A fixed iteration count either truncates on heavy days or wastes calls on quiet ones
  • Looping until a short page (or an absent nextLink) is self-tuning; the safety ceiling only guards against a misbehaving source

Trade-offs, honestly

  • A loop is harder to reason about than a single copy activity — hence per-iteration logging as a first-class concern
  • $skip paging assumes stable ordering; business-key merge and a bounded delta window contain the risk rather than eliminate it
  • Page size is an empirical tuning parameter per entity, not a constant that can be reasoned to
  • Higher upfront design effort — repaid from the second entity onward
Verdict: the constraint was designed for rather than worked around, so behaviour is identical whether the delta is fifty rows or half a million.

The capability behind it

Start a conversation

Tell us what you are trying to build

Send the problem rather than a spec. We will tell you what it takes, who would work on it, and whether we are the right people for it.

Vijeesh TP

Vijeesh TP

Founder