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 fullComponent 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
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
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,
$selectfor required fields,$filterfor the watermark, and the paging parameters - Nothing entity-specific is baked into the connection or dataset
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
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_type —
fullorincremental - 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"
}
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
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
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
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
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
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
$filterbounds 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
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
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$filterapplied - Page is landed to Bronze and merged onward before the next request
skip += page_sizehasMore = (rowsReturned == page_size)— a short page means the end
- Copy activity requests
- 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
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
Technical detail
What it handles
- SAP technical field → business-readable target column
- Data type alignment between OData and SQL
$selectprojection — 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
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
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
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
$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
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
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
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
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
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
$skippaging 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
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.

