by Joche Ojeda | Jan 21, 2026 | Uncategorized
This is a story about testing XAF applications — and why now is finally the right time to do it properly.
With Copilot agents and AI-assisted coding, writing code has become cheaper and faster than ever. Features that used to take days now take hours. Boilerplate is almost free.
And that changes something important.
For the first time, many of us actually have time to do the things we always postponed:
- documenting the source code,
- writing proper user manuals,
- and — yes — writing tests.
But that immediately raises the real question:
What kind of tests should I even write?
Most developers use “unit tests” as a synonym for “tests”. But once you move beyond trivial libraries and into real application frameworks, that definition becomes fuzzy very quickly.
And nowhere is that more obvious than in XAF.
I’ve been working with XAF for something like 15–18 years (I’ve honestly lost count). It’s my preferred application framework, and it’s incredibly productive — but testing it “as-is” can feel like wrestling a framework-shaped octopus.
So let’s clarify something first.
You don’t test the framework. You test your logic.
XAF already gives you a lot for free:
- CRUD
- UI generation
- validation plumbing
- security system
- object lifecycle
- persistence
DevExpress has already tested those parts — thousands of times, probably millions by now.
So you do not need to write tests like:
- “Can ObjectSpace save an object?”
- “Does XAF load a View?”
- “Does the security system work?”
You assume those things work.
Your responsibility is different.
You test the decisions your application makes.
That principle applies to XAF — and honestly, to any serious application framework.
The mental shift: what is a “unit”, really?
In classic theory, a unit is the smallest piece of code with a single responsibility — usually a method.
In real applications, that definition is often too small to be useful.
Sometimes the real “unit” is:
- a workflow,
- a business decision,
- a state transition,
- or a rule spanning multiple objects.
In XAF especially, the decision matters more than the method.
That’s why the right question is not “how do I unit test XAF?”
The right question is:
Which decisions in my app are important enough to protect?
The test pyramid for XAF
A practical, realistic test pyramid for XAF looks like this:
- Fast unit tests for pure logic
- Unit tests with thin seams around XAF-specific dependencies
- Integration tests with a real ObjectSpace (confidence tests)
- Minimal UI tests only for critical wiring
Let’s go layer by layer.
1) Push logic out of XAF into plain services (fast unit tests)
This is the biggest win you’ll ever get.
The moment you move important logic out of:
- Controllers
- Rules
- ObjectSpace-heavy code
…testing becomes boring — and boring is good.
Put non-UI logic into:
- Domain services (e.g.
IInvoicePricingService)
- Use-case handlers (
CreateInvoiceHandler, PostInvoiceHandler)
- Pure methods (no ObjectSpace, no View, no security calls)
Now you can test with plain xUnit / NUnit and simple mocks or fakes.
What is a service?
A service is code that makes business decisions.
It answers questions like:
- “Can this invoice be posted?”
- “Is this discount valid?”
- “What is the total?”
- “Is the user allowed to approve this?”
A service:
- contains real logic
- is framework-agnostic
- is the thing you most want to unit test
If code decides why something happens, it belongs in a service.
2) Unit test XAF-specific logic with thin seams
Some logic will always touch XAF concepts. That’s fine.
The trick is not to eliminate XAF — it’s to isolate it.
You do that by introducing seams.
What is a seam?
A seam is a boundary where you can replace a real dependency with a fake one in a test.
A seam:
- usually contains no business logic
- exists mainly for testability
- is often an interface or wrapper
Common XAF seams:
ICurrentUser instead of SecuritySystem.CurrentUser
IClock instead of DateTime.Now
- repositories / unit-of-work instead of raw
IObjectSpace
IUserNotifier instead of direct UI calls
Seams don’t decide anything — they just let you escape the framework in tests.
What does “adapter” mean in XAF?
An adapter is a very thin class whose job is to:
- translate XAF concepts (View, ObjectSpace, Actions, Rules)
- into calls to your services and use cases
Adapters:
- contain little or no business logic
- are allowed to be hard to unit test
- exist to connect XAF to your code
Typical XAF adapters:
- Controllers
- Appearance Rules
- Validation Rules
- Action handlers
- Property setters that delegate to services
The adapter is not the brain.
The brain lives in services.
What should you test here?
- Appearance Rules
Test the decision behind the rule (e.g. “Is this field editable now?”).
Then confirm via integration tests that the rule is wired correctly.
- Validation Rules
Test the validation logic itself (conditions, edge cases).
Optionally verify that the XAF rule triggers when expected.
- Calculated properties / non-trivial setters
- Controller decision logic once extracted from the Controller
3) Integration tests with a real ObjectSpace (confidence tests)
Unit tests prove your logic is correct.
Integration tests prove your XAF wiring still behaves.
They answer questions like:
- Does persistence work?
- Do validation and appearance rules trigger?
- Do lifecycle hooks behave?
- Does security configuration work as expected?
4) Minimal UI tests (only for critical wiring)
UI automation is expensive and fragile.
Keep UI tests only for:
- Critical actions
- Essential navigation flows
- Known production regressions
The key mental model
A rule is not the unit.
The decision behind the rule is the unit.
Test the decision directly.
Use integration tests to confirm the glue still works.
Closing thought
Test your app’s decisions, not the framework’s behavior.
That’s the difference between a test suite that helps you move faster
and one that quietly turns into a tax.
by Joche Ojeda | Jan 15, 2026 | DLLs, netframework
Most .NET developers eventually face it.
A project that targets .NET Framework 4.7.2, uses video and audio components, depends on vendor SDKs, and mixes managed code, native DLLs, and legacy decisions.
In other words: a brownfield project.
This is the kind of system that still runs real businesses, even if it doesn’t fit neatly into modern slides about containers and self-contained deployments.
And it’s also where many developers discover — usually the hard way — that deployment is not just copying the Release folder and hoping for the best.
The Myth: “Just Copy the EXE”
I’ve seen this mindset for years:
“It works on my machine. Just copy the EXE and the DLLs to the client.”
Sometimes it works. Often it doesn’t.
And when it fails, it fails in the most frustrating ways:
- Silent crashes
- Missing assembly errors
- COM exceptions that appear only on client machines
- Video or audio features that break minutes after startup
The real issue isn’t the DLL.
The real issue is that most developers don’t understand how .NET Framework actually resolves assemblies.
How I Learned This the Hard Way (XPO + Pervasive, 2006)
The first time I truly understood this was around 2006, while writing a custom XPO provider for Pervasive 7.
At the time, the setup was fairly typical:
- A .NET Framework application
- Using DevExpress XPO
- Talking to Pervasive SQL
- The Pervasive .NET provider lived under Program Files
- It was not registered in the GAC
On my development machine, everything worked.
On another machine? File not found. Or worse: a crash when XPO tried to initialize the provider.
The “fix” everyone used back then was almost ritual:
“Copy the Pervasive provider DLL into the same folder as the EXE.”
And suddenly… it worked.
That was my first real encounter with assembly probing — even though I didn’t know the name yet.
How Assembly Resolution Really Works in .NET Framework
.NET Framework does not scan your disk.
It does not care that a DLL exists under Program Files.
It follows a very strict resolution order.
1. Already Loaded Assemblies
If the assembly is already loaded in the current AppDomain, the CLR reuses it.
Simple.
2. Application Base Directory
Next, the CLR looks in the directory where the EXE lives.
This single rule explains years of “just copy the DLL next to the EXE” folklore.
In the Pervasive case, copying the provider locally worked because it entered the application base probing path.
3. Private Probing Paths
This is where things get interesting.
In app.config, you can extend the probing logic:
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="lib;providers;drivers" />
</assemblyBinding>
</runtime>
This tells the runtime:
“If you don’t find the assembly in the EXE folder, also look in these subfolders.”
Important details many developers miss:
- Paths are relative to the EXE
- No recursive search
- Every folder must be explicitly listed
4. Global Assembly Cache (GAC)
Only after probing the application paths does the CLR look in the GAC, and only if:
- The assembly is strong-named
- The reference includes that strong name
Two common misconceptions:
- A DLL being “installed on the system” does not matter
- Non–strong-named assemblies are never loaded from the GAC
5. AssemblyResolve: The Last-Chance Hook
If the CLR cannot find an assembly using any of the rules above, it fires:
AppDomain.CurrentDomain.AssemblyResolve
This happens at runtime, exactly when the assembly is needed.
That’s why:
- The app may start fine
- The crash happens later
- Video or database features fail “randomly”
Why Video and Audio Projects Amplify the Pain
Projects that deal with video codecs, audio pipelines, hardware acceleration, and vendor SDKs are especially vulnerable because:
- Assemblies load late
- Managed code pulls native DLLs
- Bitness matters (x86 vs x64)
- Licensing logic often lives outside managed code
The failure doesn’t happen at startup. It happens when the feature is first used.
The Final Step: Building a Real Installer
Eventually, I stopped pretending that copying files was enough.
I built a proper installer.
Even though today I often use the Visual Studio Installer Projects extension, for this legacy application I went with a WiX-based installer. Not because it was fashionable — but because it forced me to be explicit.
An installer asks uncomfortable questions:
- Which assemblies belong in the GAC?
- Which must live next to the EXE?
- Which native DLLs must be deployed together?
- Which dependencies only worked because Visual Studio copied them silently?
I had to inspect every file I was adding and make a conscious decision:
- Shared, strong-named → GAC
- App-local or version-sensitive → EXE folder
- Native dependencies → exact placement matters
The installer didn’t magically fix the application.
It revealed the truth about it.
The Real Lesson of Brownfield Work
Legacy projects don’t fail because they’re old.
They fail because nobody understands them anymore.
Once you understand assembly probing, GAC rules, runtime loading, and deployment boundaries, brownfield systems stop being mysterious.
They become predictable.
What’s Next: COM (Yes, That COM)
This application doesn’t stop at managed assemblies.
It also depends heavily on COM components.
The next article will focus entirely on that world: what COM components really are, why they survived for decades, and how to work with them safely as a .NET developer.
If assembly probing was the first reality check, COM is the one that separates “it runs on my machine” from “this can be deployed.”
by Joche Ojeda | Jan 8, 2026 | netframework
If you’ve ever worked on a traditional .NET Framework application — the kind that predates .NET Core and .NET 5+ — this story may feel painfully familiar.
I’m talking about classic .NET Framework 4.x applications (4.0, 4.5, 4.5.1, 4.5.2, 4.6, 4.6.1, 4.6.2, 4.7, 4.7.1, 4.7.2, 4.8, and the final release 4.8.1). These systems often live long, productive lives… and accumulate interesting technical debt along the way.
This particular system is written in C# and relies heavily on COM components to render video, audio, and PDF content. Under the hood, many of these components are based on technologies like DirectShow filters, ActiveX controls, or other native COM DLLs.
And that’s where the story begins.
The Setup: COM, DirectShow, and Registration
Unlike managed .NET assemblies, COM components don’t just live quietly next to your executable. They need to be registered in the system registry so Windows knows:
- What CLSID they expose
- Which DLL implements that CLSID
- Whether it’s 32-bit or 64-bit
- How it should be activated
For DirectShow-based components (very common for video/audio playback in legacy apps), registration is usually done manually during development using regsvr32.
Example:
regsvr32 MyVideoFilter.dll
To unregister:
regsvr32 /u MyVideoFilter.dll
Important detail that bites a lot of people:
- 32-bit DLLs must be registered using:
C:\Windows\SysWOW64\regsvr32.exe My32BitFilter.dll
- 64-bit DLLs must be registered using:
C:\Windows\System32\regsvr32.exe My64BitFilter.dll
Yes — the folder names are historically confusing.
Development Works… Until It Doesn’t
So here’s the usual development flow:
- You register all required COM DLLs on your development machine
- Visual Studio runs the app
- Video plays, audio works, PDFs render
- Everyone is happy
Then comes the next step.
“Let’s build an installer.”
The Installer Paradox
This is where the real battle story begins.
Your application installer (MSI, InstallShield, WiX, Inno Setup — pick your poison) now needs to:
- Copy the COM DLLs
- Register them during installation
- Unregister them during uninstall
This seems reasonable… until you test it.
The Loop From Hell
Here’s what happens in practice:
- You install your app for testing
- The installer registers its own copies of the COM DLLs
- Your development environment was using different copies (maybe newer, maybe local builds)
- Suddenly:
- Your source build stops working
- Visual Studio debugging breaks
- Another app on your machine mysteriously fails
Then you:
- Uninstall the app
- The installer unregisters the DLLs
- Now nothing works anymore
So you re-register the DLLs manually for development…
…and the cycle repeats.
The Battle Story: It Only Worked… Until It Didn’t
For a long time, this system appeared to work just fine.
Video played. Audio rendered. PDFs opened. No obvious errors.
What we didn’t realize at first was a dangerous hidden assumption:
The system only worked on machines where a previous version had already been installed.
Those older installations had left COM DLLs registered in the system — quietly, globally, and invisibly.
So when we deployed a new version without removing the old one:
- Everything looked fine
- No one suspected missing registrations
- The system passed casual testing
The illusion broke the moment we tried a clean installation.
On a fresh machine — no previous version, no leftover registry entries — the application suddenly failed:
- Components didn’t initialize
- Media rendering silently broke
- COM activation errors appeared only in Event Viewer
The installer claimed it was registering the DLLs.
In reality, it wasn’t doing it correctly — or at least not in the way the application actually needed.
That’s when we realized we were standing on years of accidental state.
Why This Happens
The core problem is simple but brutal:
COM registration is global and mutable.
There is:
- One registry
- One CLSID mapping
- One “active” DLL per COM component
Your development environment, your installed application, and your installer are all fighting over the same global state.
.NET Framework itself isn’t the villain here — it’s just sitting on top of an old Windows integration model that predates modern isolation concepts.
A New Player Enters: ARM64
Just when we thought the problem space was limited to x86 vs x64, another variable entered the scene.
One of the development machines was ARM64.
Modern Windows on ARM adds a new layer of complexity:
- ARM64 native processes
- x64 emulation
- x86 emulation on top of ARM64
From the outside, everything looks like it’s running on x64.
Under the hood, it’s not that simple.
Why This Makes COM Registration Worse
COM registration is architecture-specific:
- x86 DLLs register under one view of the registry
- x64 DLLs register under another
- ARM64 introduces yet another execution context
On Windows ARM:
System32 contains ARM64 binaries
SysWOW64 contains x86 binaries
- x64 binaries often run through emulation layers
So now the questions multiply:
- Which
regsvr32 did the installer call?
- Was it ARM64, x64, or x86?
- Did the app run natively, or under emulation?
- Did the COM DLL match the process architecture?
The result is a system where:
- Some things work on Intel machines
- Some things work on ARM machines
- Some things only work if another version was installed first
At this point, debugging stops being logical and starts being archaeological.
Why This Is So Common in .NET Framework 4.x Apps
Many enterprise and media-heavy applications built on:
- .NET Framework 4.0–4.8.1
- WinForms or WPF
- DirectShow or ActiveX components
were designed in an era where:
- Global COM registration was normal
- Side-by-side isolation was rare
- “Just register the DLL” was accepted practice
These systems work, but they’re fragile — especially on developer machines.
Where the Article Is Going Next
In the rest of this article series, we’ll look at:
- Why install-time registration is often a mistake
- How to isolate development vs runtime environments
- Techniques like:
- Dedicated dev VMs
- Registration-free COM (where possible)
- App-local COM deployment
- Clear ownership rules for installers
- How to survive (and maintain) legacy .NET Framework systems without losing your sanity
If you’ve ever broken your own development environment just by testing your installer — you’re not alone.
This is the cost of living at the intersection of managed code and unmanaged history.
by Joche Ojeda | Dec 23, 2025 | ADO, ADO.NET, XPO
One of the recurring challenges in real-world systems is not building new software — it’s
integrating with software that already exists.
Legacy systems don’t disappear just because newer technologies are available. They survive because they work,
because they hold critical business data, and because replacing them is often risky, expensive, or simply not allowed.
This article explores a practical approach to accessing legacy data using XPO by leveraging ODBC,
not as a universal abstraction, but as a bridge when no modern provider exists.
The Reality of Legacy Systems
Many organizations still rely on systems built on technologies such as:
- FoxPro tables
- AS400 platforms
- DB2-based systems
- Proprietary or vendor-abandoned databases
In these scenarios, it’s common to find that:
- There is no modern .NET provider
- There is no ORM support
- There is an ODBC driver
That last point is crucial. ODBC often remains available long after official SDKs and providers have disappeared.
It becomes the last viable access path to critical data.
Why ORMs Struggle with Legacy Data
Modern ORMs assume a relatively friendly environment: a supported database engine, a known SQL dialect,
a compatible type system, and an actively maintained provider.
Legacy databases rarely meet those assumptions. As a result, teams are often forced to:
- Drop down to raw SQL
- Build ad-hoc data access layers
- Treat legacy data as a second-class citizen
This becomes especially painful in systems that already rely heavily on DevExpress XPO for persistence,
transactions, and domain modeling.
ODBC Is Not Magic — and That’s the Point
ODBC is often misunderstood.
Using ODBC does not mean:
- One provider works for every database
- SQL becomes standardized
- Type systems become compatible
Each ODBC-accessible database still has:
- Its own SQL dialect
- Its own limitations
- Its own data types
- Its own behavioral quirks
ODBC simply gives you a way in. It is a transport mechanism, not a universal language.
What an XPO ODBC Provider Really Is
When you implement an XPO provider on top of ODBC, you are not building a generic solution for all databases.
You are building a targeted adapter for a specific legacy system that happens to be reachable via ODBC.
This matters because ODBC is used here as a pragmatic trick:
- To connect to something you otherwise couldn’t
- To reuse an existing, stable access path
- To avoid rewriting or destabilizing legacy systems
The database still dictates the SQL dialect, supported features, and type system. Your provider must respect those constraints.
Why XPO Makes This Possible
XPO is not just an ORM — it is a provider-based persistence framework.
All SQL-capable XPO providers are built on top of a shared foundation, most notably:
ConnectionProviderSql
https://docs.devexpress.com/CoreLibraries/DevExpress.Xpo.DB.ConnectionProviderSql
This architecture allows you to reuse XPO’s core benefits:
- Object model
- Sessions and units of work
- Transaction handling
- Integration with domain logic
While customizing what legacy systems require:
- SQL generation
- Command execution
- Schema discovery
- Type mapping
Dialects and Type Systems Still Matter
Even when accessed through ODBC:
- FoxPro is not SQL Server
- DB2 is not PostgreSQL
- AS400 is not Oracle
Each system has its own:
- Date and time semantics
- Numeric precision rules
- String handling behavior
- Constraints and limits
An XPO ODBC provider must explicitly map database types, handle dialect-specific SQL,
and avoid assumptions about “standard SQL.” ODBC opens the door — it does not normalize what’s inside.
Real-World Experience: AS400 and DB2 in Production
This approach is not theoretical. Last year, we implemented a custom XPO provider using ODBC for
AS400 and DB2 systems in Mexico, where:
- No viable modern .NET provider existed
- The systems were deeply embedded in business operations
- ODBC was the only stable integration path
By introducing an XPO provider on top of ODBC, we were able to integrate legacy data into a modern .NET architecture,
preserve domain models and transactional behavior, and avoid rewriting or destabilizing existing systems.
The Hidden Advantage: Modern UI and AI Access
Once legacy data is exposed through XPO, something powerful happens: that data becomes immediately available to modern platforms.
- Blazor applications
- .NET MAUI mobile and desktop apps
- Background services
- Integration APIs
- AI agents and assistants
And you get this without rewriting the database, migrating the data, or changing the legacy system.
XPO becomes the adapter that allows decades-old data to participate in modern UI stacks, automated workflows,
and AI-driven experiences.
Why Not Just Use Raw ODBC?
Raw ODBC gives you rows, columns, and primitive values. XPO gives you domain objects, identity tracking,
relationships, transactions, and a consistent persistence model.
The goal is not to modernize the database. The goal is to modernize access to legacy data
so it can safely participate in modern architectures.
Closing Thought
An XPO ODBC provider is not a silver bullet. It will not magically unify SQL dialects, type systems, or database behavior.
But when used intentionally, it becomes a powerful bridge between systems that cannot be changed
and architectures that still need to evolve.
ODBC is the trick that lets you connect.
XPO is what makes that connection usable — everywhere, from Blazor UIs to AI agents.
by Joche Ojeda | Dec 23, 2025 | ADO, ADO.NET, C#
When I started working with computers, one of the tools that shaped my way of thinking as a developer was FoxPro.
At the time, FoxPro felt like a complete universe: database engine, forms, reports, and business logic all integrated into a single environment.
Looking back, FoxPro was effectively an application framework from the past—long before that term became common.
Accessing FoxPro data usually meant choosing between two paths:
- Direct FoxPro access – fast, tightly integrated, and fully aware of FoxPro’s features
- ODBC – a standardized way to access the data from outside the FoxPro ecosystem
This article focuses on that second option.
What Is ODBC?
ODBC (Open Database Connectivity) is a standardized API for accessing databases.
Instead of applications talking directly to a specific database engine, they talk to an ODBC driver,
which translates generic database calls into database-specific commands.
The promise was simple:
One API, many databases.
And for its time, this was revolutionary.
Supported Operating Systems and Use Cases
ODBC is still relevant today and supported across major platforms:
- Windows – native support, mature tooling
- Linux – via unixODBC and vendor drivers
- macOS – supported through driver managers
Typical use cases include:
- Legacy systems that must remain stable
- Reporting and BI tools
- Data migration and ETL pipelines
- Cross-vendor integrations
- Long-lived enterprise systems
ODBC excels where interoperability matters more than elegance.
The Lowest Common Denominator Problem
Although ODBC is a standard, it does not magically unify databases.
Each database has its own:
- SQL dialect
- Data types
- Functions
- Performance characteristics
ODBC standardizes access, not behavior.
You can absolutely open an ODBC connection and still:
- Call native database functions
- Use vendor-specific SQL
- Rely on engine-specific behavior
This makes ODBC flexible—but not truly database-agnostic.
ODBC vs True Abstraction Layers
This is where ODBC differs from ORMs or persistence frameworks that aim for full abstraction.
- ODBC: Gives you a common door and does not prevent database-specific usage
- ORM-style frameworks: Try to hide database differences and enforce a common conceptual model
ODBC does not protect you from database specificity—it permits it.
ODBC in .NET: Avoiding Native Database Dependencies
This is an often-overlooked advantage of ODBC, especially in .NET applications.
ADO.NET is interface-driven:
IDbConnection
IDbCommand
IDataReader
However, each database requires its own concrete provider:
- SQL Server
- Oracle
- DB2
- Pervasive
- PostgreSQL
- MySQL
Each provider introduces:
- Native binaries
- Vendor SDKs
- Version compatibility issues
- Deployment complexity
Your code may be abstract — your deployment is not.
ODBC as a Binary Abstraction Layer
When using ODBC in .NET, your application depends on one provider only:
System.Data.Odbc
Database-specific dependencies are moved:
- Out of your application
- Into the operating system
- Into driver configuration
This turns ODBC into a dependency firewall.
Minimal .NET Example: ODBC vs Native Provider
Native ADO.NET Provider (Example: SQL Server)
using System.Data.SqlClient;
using var connection =
new SqlConnection("Server=.;Database=AppDb;Trusted_Connection=True;");
connection.Open();
Implications:
- Requires SQL Server client libraries
- Ties the binary to SQL Server
- Changing database = new provider + rebuild
ODBC Provider (Database-Agnostic Binary)
using System.Data.Odbc;
using var connection =
new OdbcConnection("DSN=AppDatabase");
connection.Open();
Implications:
- Same binary works for SQL Server, Oracle, DB2, etc.
- No vendor-specific DLLs in the app
- Database choice is externalized
The SQL inside the connection may still be database-specific — but your application binary is not.
Trade-Offs (And Why They’re Acceptable)
Using ODBC means:
- Fewer vendor-specific optimizations
- Possible performance differences
- Reliance on driver quality
But in exchange, you gain:
- Simpler deployments
- Easier migrations
- Longer application lifespan
- Reduced vendor lock-in
For many enterprise systems, this is a strategic win.
What’s Next – Phase 2: Customer Polish
Phase 1 is about making it work.
Phase 2 is about making it survivable for customers.
In Phase 2, ODBC shines by enabling:
- Zero-code database switching
- Cleaner installers
- Fewer runtime surprises
- Support for customer-controlled environments
- Reduced friction in on-prem deployments
This is where architecture meets reality.
Customers don’t care how elegant your abstractions are — they care that your software runs on their infrastructure without drama.
Project References
Minimal and explicit:
System.Data
System.Data.Odbc
Optional (native providers, when required):
System.Data.SqlClient
Oracle.ManagedDataAccess
IBM.Data.DB2
ODBC allows these to become optional, not mandatory.
Closing Thought
ODBC never promised purity.
It promised compatibility.
Just like FoxPro once gave us everything in one place, ODBC gave us a way out — without burning everything down.
Decades later, that trade-off still matters.