Back to blog

Legacy System Integration Challenges: No-API Routes Ranked by Risk

Legacy system integration challenges with no API: ten routes ranked by risk, from database reads and CDC to IBM i and email, with how to detect each failure.

Luka Abramovic21 min read

Isometric legacy and modern applications joined by database, file and screen-automation routes.

The costliest legacy system integration challenges come from integrations that change the system they read from. Switch on SQL Server change data capture (CDC) for a vendor's table, and any vendor job that empties that table with TRUNCATE TABLE starts failing with error 4711. Let CDC's capture job stop, and the transaction log stops truncating, even in the simple recovery model, so it grows until the log or its disk is full (Microsoft Learn). A test that only checks whether changes arrive passes either way.

Below are the ways into a system with no API, ranked by how much each can harm it or silently lose data, with what goes wrong, how to detect it and what to ask the vendor. SQL Server and IBM i (AS/400) get the most detail. Product facts are as of September 2026.

The routes, ranked by risk

Our ranking, from least risk to most. In the middle column, anything other than "nothing" needs the vendor's agreement before anyone builds.

Integration routes into a system with no API, from least to most risk
RouteWhat it changes on the legacy sideHow it fails quietly
1. The vendor's scheduled exportNothingA partial, late or repeated file. Deleted records simply stop appearing
2. Queries against a restored copyNothing on productionThe copy is older than everyone assumes
3. Read-only queries against the live databaseLoad and locksNOLOCK reads skip or double-count rows
4. Email in or outNothing, but it depends on your mail platformThrottled sends, or a mailbox reader that stops when Microsoft retires EWS
5. Change trackingTracking metadata on each tableChanges are cleaned up before you read them. Vendor scripts that drop a primary key fail
6. Change data captureChange tables, two SQL Server Agent jobs and a hold on log truncationThe log grows, truncates fail and new columns are ignored
7. Calling the application's own programs (IBM i)Only what the application's own program writesThe program runs with a different library list and opens other files
8. Screen automation (RPA)A user account and a machineThe bot types into the wrong field and carries on
9. Writing to the vendor's tables (avoid)Rows the application didn't createRecords that skip validation, totals and audit rows
10. Triggers on the vendor's tables (avoid)Your code runs inside their transactionsYour error rolls back their save

Before you connect: what the vendor allows

Reading a vendor's database is technically easy and contractually uncertain. Three documents decide it:

  1. The license agreement, including the database engine's: if the vendor supplied the SQL Server or Oracle license, ask whether it covers other programs connecting.
  2. The support terms. Search for "database", "third party", "direct access", "modification" and "unsupported". Read exclusions knowing that read-only queries change nothing in the database, while change tracking, CDC and triggers add objects to it.
  3. The upgrade notes: do upgrades drop and recreate tables, taking anything you attached with them?

Then ask in writing, from whoever owns the vendor relationship:

Subject: Reading data from [product] outside the application

Hello [name],

We want to use data from [product] in [other system] and need your answers in writing:

1. Do our license and support agreement permit read-only access to the
   database by another program, including under a database license you supplied?
2. Is there a supported way to read the data (views, a reporting database,
   a scheduled export) that we should use instead of the base tables?
3. If our DBA enables SQL Server change tracking or change data capture on these
   tables: [list], does that affect support? Do your upgrade scripts drop and
   recreate tables or primary keys, use TRUNCATE TABLE, or switch partitions?
4. Which tables hold [customers / orders / invoices]? Which column shows when a
   row last changed, and what sets it? Are rows ever physically deleted?
5. Is there a supported way to create or update [record] from outside: an
   import, a program or procedure we can call, or a queue the application reads?
6. Which jobs run inside the database, and when? How much notice do you
   give of schema changes before an upgrade?

Thank you,
[name, role, customer account number]

Questions 3 and 5 decide the most. A "yes" to TRUNCATE TABLE rules out CDC on that table. A "yes" to rebuilding primary keys means switching change tracking or CDC off before each upgrade and reloading the table afterwards.

Whatever the answers, our default is to put views, state tables and logs in a separate database on the same server, where upgrades can't drop them and the vendor's support team never finds objects nobody explained.

Reading the vendor's database without harming it

Run the inventory first

These read-only queries show which routes a SQL Server allows. A DBA runs queries 4 and 5 in the vendor's database. Query 2 needs VIEW SERVER STATE (SQL Server 2019 and earlier) or VIEW SERVER SECURITY STATE (2022 and later).

-- 1. Edition and version: is CDC available at all?
SELECT SERVERPROPERTY('Edition') AS edition,
       SERVERPROPERTY('ProductVersion') AS version;

-- 2. Is SQL Server Agent running? CDC's jobs need it
SELECT servicename, status_desc FROM sys.dm_server_services;

-- 3. Features in use, and what each log is waiting on
SELECT d.name, d.is_cdc_enabled, d.log_reuse_wait_desc,
       d.snapshot_isolation_state_desc, d.is_read_committed_snapshot_on,
       ct.retention_period, ct.retention_period_units_desc
FROM sys.databases AS d
LEFT JOIN sys.change_tracking_databases AS ct ON ct.database_id = d.database_id;

-- 4. In the vendor database: tables with no primary key
SELECT s.name AS schema_name, t.name AS table_name
FROM sys.tables AS t
JOIN sys.schemas AS s ON s.schema_id = t.schema_id
WHERE OBJECTPROPERTY(t.object_id, 'TableHasPrimaryKey') = 0;

-- 5. In the vendor database: tables with a rowversion column
SELECT OBJECT_SCHEMA_NAME(c.object_id) AS schema_name,
       OBJECT_NAME(c.object_id) AS table_name, c.name AS column_name
FROM sys.columns AS c
JOIN sys.types AS ty ON ty.user_type_id = c.user_type_id
JOIN sys.tables AS t ON t.object_id = c.object_id
WHERE ty.name IN ('timestamp', 'rowversion');
  • Query 1: CDC needs Standard or Enterprise. SQL Server 2022's Web and Express editions don't include it, and Express has no SQL Server Agent. Express caps each database at 10 GB in SQL Server 2022 and 50 GB in SQL Server 2025 (2022 editions, 2025 editions). Change tracking works in every edition, but its internal tables count against the Express cap.
  • Query 4: change tracking can't track a table without a primary key. CDC can, but without net changes, which need a primary key or unique index (sys.sp_cdc_enable_table).

Don't read with NOLOCK

WITH (NOLOCK) is the usual advice for reading a busy database without blocking it. Microsoft documents the cost: besides reading changes that are later rolled back, a NOLOCK scan can miss rows while another transaction splits a page. Even the default READ COMMITTED level can read a row twice, or miss it, when another transaction changes the row's index key mid-scan (locking and row versioning guide). For an integration that counts or sums, that means wrong totals with no error.

If query 3 shows snapshot isolation allowed, read inside SET TRANSACTION ISOLATION LEVEL SNAPSHOT: a consistent picture, without blocking the vendor's writes. Turning it on changes the vendor's database: row versioning adds up to 14 bytes to each row as it is modified, 14% on a 100-byte row. Ask first. Until then, keep reads short, split by date or ID range, and out of busy windows such as month-end posting.

A readable secondary replica would avoid all this, but Standard edition's basic availability groups allow no reads on the secondary (basic availability groups). On Standard, the alternative is a nightly restored copy on another server, a day old by design, so stamp every extract with the newest record date it contains.

Change tracking vs change data capture

Both let you read only what changed, and both record deletions, which polling a last-modified column can't see. They differ in what they record and what they cost the vendor's database.

SQL Server change tracking and change data capture compared, as of September 2026
QuestionChange trackingChange data capture
EditionsAll, including ExpressStandard and Enterprise. Not Express or Web
Needs SQL Server AgentNoYes, for the capture and cleanup jobs
What it recordsThe primary key and the operation. Current values are read from the table, so intermediate values are lostEvery insert, delete and update, with values. Each update writes two rows: before and after
Default retention2 days3 days (4,320 minutes). The cleanup job runs daily at 2 a.m.
Table requirementA primary keyNone. Net changes need a primary key or unique index
How it is switched onALTER DATABASE, then ALTER TABLE for each tablesys.sp_cdc_enable_db (needs sysadmin), then sys.sp_cdc_enable_table for each table (db_owner)
What it blocks in the vendor's databaseDropping the table's primary key and switching partitions. TRUNCATE TABLE still works but forces every reader into a full reloadTRUNCATE TABLE fails with error 4711. The primary key can't change until CDC is disabled on the table
Schema changesA dropped column can still appear in column tracking. Data type changes aren't trackedColumns added later are ignored until you create a second capture instance (two per table at most)
How changes are capturedSynchronously, as part of each insert, update or deleteAsynchronously, by a job reading the transaction log. The log can't truncate past changes the job hasn't read, in any recovery model
How you know you missed changesCHANGE_TRACKING_MIN_VALID_VERSION is higher than your last versionYour start point is below sys.fn_cdc_get_min_lsn, and the query functions fail

Sources: Manage change tracking, enable and disable CDC, error 4711 and the CDC overview. One more CDC trap: it doesn't work properly when the Database Engine or Agent service runs as NETWORK SERVICE (error 22832).

Our default is change tracking whenever each changed record's current state is enough, as in most syncs. Choose CDC when you need every state a record passed through (each status an order held, for time-in-status reporting or audit) or the table has no primary key. CDC-based streaming tools such as Debezium's SQL Server connector need a running Agent too (Debezium documentation), so the CDC column applies to them.

Size retention for your longest outage, not your sync interval

An illustrative example. The integration's server fails at 18:00 on the Friday before a holiday weekend and is restarted at 09:00 on Tuesday: 87 hours. Change tracking's default keeps 48 hours and CDC's keeps 72, so both have cleaned up Friday evening's changes before the integration asks. Either way, you reload every table in full.

Our default is seven days: CHANGE_RETENTION = 7 DAYS, or 10,080 minutes for CDC. The cost is storage. A table with 5,000 inserts and 20,000 updates a day adds 45,000 CDC rows a day, because each update writes two: 315,000 rows at seven days, against 135,000 at three. A CDC retention change takes effect only after the cleanup job restarts (sys.sp_cdc_change_job):

EXECUTE sys.sp_cdc_change_job @job_type = N'cleanup', @retention = 10080;
EXECUTE sys.sp_cdc_stop_job  @job_type = N'cleanup';
EXECUTE sys.sp_cdc_start_job @job_type = N'cleanup';

Read change tracking so a cleanup can't fool you

Microsoft recommends reading changes inside a snapshot transaction, because the cleanup can otherwise run between your version check and your read and return incomplete results (Work with change tracking). The integration's state lives in its own database and is read before the transaction starts:

-- Once, by the DBA, after the vendor agrees:
ALTER DATABASE VendorDb SET ALLOW_SNAPSHOT_ISOLATION ON;
ALTER DATABASE VendorDb SET CHANGE_TRACKING = ON (CHANGE_RETENTION = 7 DAYS, AUTO_CLEANUP = ON);
ALTER TABLE dbo.OrderHeader ENABLE CHANGE_TRACKING;

-- Each run, in VendorDb. @last is the version saved by the previous run
-- (after the first full load, the CHANGE_TRACKING_CURRENT_VERSION() taken before it):
DECLARE @last bigint = (SELECT last_version FROM IntegrationDb.dbo.SyncState
                        WHERE table_name = 'OrderHeader');
DECLARE @next bigint;

SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
  IF @last < CHANGE_TRACKING_MIN_VALID_VERSION(OBJECT_ID('dbo.OrderHeader'))
    THROW 50001, 'Changes were cleaned up before we read them. Full reload needed.', 1;
  SET @next = CHANGE_TRACKING_CURRENT_VERSION();
  SELECT ct.OrderId, ct.SYS_CHANGE_OPERATION, o.Status, o.TotalAmount
  FROM CHANGETABLE(CHANGES dbo.OrderHeader, @last) AS ct
  LEFT JOIN dbo.OrderHeader AS o ON o.OrderId = ct.OrderId;
COMMIT TRANSACTION;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- Only after the rows are safely stored downstream:
UPDATE IntegrationDb.dbo.SyncState SET last_version = @next WHERE table_name = 'OrderHeader';

Deleted orders come back with SYS_CHANGE_OPERATION = 'D' and empty columns. Change tracking records every update, even one that sets a column to its current value, so compare values before sending anything downstream.

Watch CDC where it breaks

Four read-only checks for the DBA or a scheduled job:

-- REPLICATION here, on a database with no replication, means the capture job is behind or stopped
SELECT name, log_reuse_wait_desc FROM sys.databases WHERE is_cdc_enabled = 1;

-- Recent capture errors
SELECT TOP (20) entry_time, error_number, error_message
FROM sys.dm_cdc_errors ORDER BY entry_time DESC;

-- Capture latency in seconds (row 0 holds the last nonzero value recorded)
SELECT latency FROM sys.dm_cdc_log_scan_sessions WHERE session_id = 0;

-- Schema changes to a captured table: new columns here are not being captured
EXECUTE sys.sp_cdc_get_ddl_history @capture_instance = N'dbo_OrderHeader';

Microsoft's full-log troubleshooting script also blames replication or CDC agents for a REPLICATION wait (error 9002 guide). Put the first query on an alert.

When you can't enable either: rowversion polling

If query 5 found a rowversion column (older scripts call it timestamp), you can find changed rows without changing the vendor's database. SQL Server stamps a per-database counter on every row inserted or updated in that table (rowversion). Two traps:

  • In-flight transactions. Save @@DBTS as your high-water mark and a transaction that took a lower number but hadn't committed when you read is skipped for good. Microsoft provides MIN_ACTIVE_ROWVERSION() for exactly this case (MIN_ACTIVE_ROWVERSION).
  • Updates that change nothing still increment it. A vendor job that touches every row overnight makes the whole table look changed.
DECLARE @from binary(8) = (SELECT last_rowversion FROM IntegrationDb.dbo.SyncState
                           WHERE table_name = 'OrderHeader');   -- 0x0000000000000000 on the first run
DECLARE @to   binary(8) = MIN_ACTIVE_ROWVERSION();

SELECT OrderId, Status, TotalAmount
FROM dbo.OrderHeader
WHERE RowVer >= @from AND RowVer < @to;

-- Only after the rows are safely stored downstream:
UPDATE IntegrationDb.dbo.SyncState SET last_rowversion = @to WHERE table_name = 'OrderHeader';

Deletions stay invisible to this method, so pair it with a weekly key comparison like the one in the exports section.

Triggers: why not

A trigger looks like the neat answer: every change to the vendor's table writes a row to your queue. But a trigger and the statement that fires it are treated as a single transaction (DML triggers). When your trigger fails (a full queue table, a missing permission, a linked server that is down), the vendor's own save rolls back, and the user sees an error nobody at the vendor can reproduce. Three more reasons:

  • One firing, many rows. A statement changing 500 rows fires the trigger once, with 500 rows in inserted (multirow triggers). A trigger written for one row processes one and drops the rest.
  • Identity values. If your trigger inserts into a table with an IDENTITY column, a vendor program that reads @@IDENTITY after saving an order gets your queue row's number instead of the new order's (@@IDENTITY).
  • Upgrades. An upgrade script that drops and recreates the table silently takes the trigger with it. One that rewrites every row floods your queue.

If the vendor refuses change tracking and CDC but agrees to a trigger in writing, use the least-bad shape: set-based, writing only keys to a queue table with no IDENTITY column, in the same database, calling nothing outside. A queue in another database would roll back the vendor's save whenever that database is unavailable:

CREATE TRIGGER dbo.trg_OrderHeader_queue ON dbo.OrderHeader
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
  SET NOCOUNT ON;
  INSERT INTO dbo.IntegrationQueue (table_name, key_value, queued_at)
  SELECT 'OrderHeader', k.OrderId, SYSUTCDATETIME()
  FROM (SELECT OrderId FROM inserted
        UNION
        SELECT OrderId FROM deleted) AS k;
END;

The integration reads the queue, then fetches current values. Detect: after every vendor upgrade, list the database's triggers with the query in our legacy system examples and confirm yours is still there and enabled.

Vendor exports: what the file leaves out

The vendor's own export is the lowest-risk route, because the vendor maintains it. Defenses for the file itself (temporary names, control totals, hashes, an alert when nothing arrives) are in our guide to legacy system challenges. The traps below are in what the file contains.

  1. Full or changes-only. Unless a changes-only export carries a delete flag, a deleted record simply stops appearing and you keep it forever. With full exports, find deletions by comparing keys, but check the row count first, or a half-written export deletes half your records. Our default: apply no deletions when today's file has more than 5% fewer rows than yesterday's, and alert instead.
  2. "Changed since" by which clock. A changes-only export usually selects on a last-modified column the application sets. A fix made directly in the database, a bulk correction by the vendor's support team or a back-dated entry may not update it, and the row never appears. Ask what sets it.
  3. Consistency. An export that reads orders, then order lines, while people work can include lines for an order created between the two reads, with no header in the file. Check for orphans before loading.
  4. Timing. An export scheduled inside the hour when clocks change can run twice or not at all that night, so schedule outside it and carry timestamps in UTC.
-- Deletions: keys in yesterday's full export that are missing today.
-- Apply them only if today's row count is at least 95% of yesterday's.
SELECT customer_no FROM stage.customers_yesterday
EXCEPT
SELECT customer_no FROM stage.customers_today;

-- Orphans: order lines whose order header is not in the same export
SELECT l.order_no, COUNT(*) AS line_count
FROM stage.order_lines AS l
LEFT JOIN stage.orders AS o ON o.order_no = l.order_no
WHERE o.order_no IS NULL
GROUP BY l.order_no;

Ask the vendor about all four points before you design the load.

IBM i (AS/400): ODBC, JDBC and calling the program

Db2 for i is a full SQL database, reachable through IBM's own drivers: the IBM i Access ODBC Driver, and JTOpen, the open-source IBM Toolbox for Java with its JDBC driver (JTOpen on GitHub). Connecting is easy. The defaults are where the data goes wrong.

Ask the IBM i administrator first

  1. Which library holds production data, and which hold copies (test, last year, a backup)? The same file names often exist in several, and your connection settings decide which you read.
  2. Which job description, and so which library list, do the application's users run with?
  3. Does any file you need have more than one member? A member is a separate set of rows inside one file (/QSYS.LIB/ORDLIB.LIB/ORDHIST.FILE/Y2025.MBR). JTOpen's record-level access reads the first member when you don't name one (AS400File). Test that your query returns what the application shows for a period stored in a later member.
  4. Which character fields are tagged CCSID 65535 (binary), and do any hold real binary data? How are dates stored: DATE columns, or numbers in a format someone must tell you? Does the application use commitment control?

Connection settings that change the data you get

These are JTOpen's JDBC defaults as of September 2026 (JDBC properties). The ODBC driver has over 70 connection options of its own (IBM's ODBC guide), including naming, default libraries and commit mode, so check those there.

JTOpen JDBC defaults that change what an integration reads
PropertyDefaultWhat goes wrongSet it to
translate binaryfalseText fields tagged CCSID 65535 arrive as bytestrue, unless some of those fields hold real binary data
trim char fieldsfalseCHAR values arrive padded, so C1001 elsewhere doesn't match C1001 plus five spacestrue, or trim before matching
transaction isolationread uncommitted. With auto-commit on (also the default), the driver runs under *NONE unless true autocommit is setYou read rows another job hasn't committed and may roll backread committed plus true autocommit=true, if the application uses commitment control
namingsqlWith no default schema or libraries set, unqualified names resolve to a schema named after your user profilesystem, with the application's library list
librariesThe server job's library listUnder system naming, you read whichever library comes firstThe production list from question 1
date formatThe server job'sDate literals in your SQL are read in the job's formatiso
jdbc:as400://ibmi.example.local;naming=system;libraries=*LIBL,ORDLIB;translate binary=true;trim char fields=true;transaction isolation=read committed;true autocommit=true;date format=iso

With *LIBL first, the libraries after it are added to the server job's library list rather than replacing it.

Writes: call the program, not the file

The application's rules live in its RPG or COBOL programs, not its files, so inserting rows into a physical file skips them. If the screen that creates an order calls a program, ask whether it can be called without the screen. JTOpen's ProgramCall runs it (ProgramCall), and its documentation notes the detail that matters: in a host server job, the library list is the initial one from the job description in the calling user profile. Give the integration's profile the job description the application's users have, or the program can open another library's files.

If the application already reads a data queue for incoming work, JTOpen's DataQueue class can write to it. Ask which programs are safe to call outside their screens, because some rely on setup the screen does first. Detect: read each record back and compare every field you sent.

Email as the integration

Old systems often already email invoices, statements and reports, so email is a tempting integration in both directions. Microsoft 365 is changing underneath both.

When the legacy system sends through Microsoft 365

Many older applications send through SMTP AUTH, signing in to a mailbox with a username and password. As of September 2026, Microsoft documents these per-mailbox limits for that route (SMTP submission throttling):

  • Three concurrent connections. More are refused with 432 4.3.2 Concurrent connections limit exceeded.
  • 30 messages per minute. The excess is delayed into the following minutes.
  • 10,000 recipients per day (Exchange Online limits). After that, sends fail with 554 5.2.0 ... SubmissionQuotaExceededException.
  • Every message sent is saved in the mailbox's Sent Items, so an application mailbox grows toward its storage limit. Our default is a retention policy that empties Sent Items.

An illustrative month end: 2,400 invoices and 3,000 statements, each to two recipients (the customer and the account manager). That is 10,800 recipients, so the last 800, about 400 messages, fail with 554 5.2.0, and at 30 a minute the run takes at least 180 minutes. The limits apply per mailbox, so our default is one mailbox per sending job: split across two, invoices (4,800 recipients) and statements (6,000) both fit, and the run takes 100 minutes. A relay connector that authenticates your server by public IP address or TLS certificate is another route (Exchange Online relay options).

The stored password has a deadline too. At the end of December 2026, Microsoft turns off Basic authentication for SMTP AUTH by default in existing tenants. Admins can turn it back on, but only until a final removal date that Microsoft will announce in the second half of 2027 (Exchange Team), so plan OAuth or a relay now. Our list of software end-of-support dates has the commands that list the mailboxes with SMTP AUTH switched on.

Ask: does the application authenticate with a password, OAuth or a relay? Does it retry on a 4xx reply, and log each message? Detect: compare messages sent each day with documents generated.

When your integration reads a mailbox

If your integration collects the legacy system's emailed reports from a mailbox, check how it connects. Microsoft is retiring Exchange Web Services (EWS) from Exchange Online. From October 2026 it is switched off tenant by tenant wherever an admin hasn't explicitly enabled it for named apps, and it is retired completely in April 2027 (Microsoft Learn). Microsoft 365 admin center > Reports > Usage > Exchange, then the EWS usage tab, lists each application ID still calling it, with its SOAP actions, call volume and last activity date (EWS usage report). Move those readers to Microsoft Graph.

Our default: a shared mailbox, with each processed message moved to a Processed folder and each failure to an Exceptions folder, so the inbox holds only unread work. Detect: alert when the report hasn't arrived by its expected time.

Screen automation, last

When a write must go through the screens and there is no import, callable program or queue, a bot that drives the screen is what remains. It changes nothing in the database, which is why it ranks above direct writes and triggers, but it breaks on a changed window title or a moved field. Our guide to workflow automation vs RPA covers selectors, unattended bots and silent failures. Two rules for legacy systems: every bot write gets a read-back through a read route above, and on IBM i green screens, look for the program behind the screen first.

What to check once it runs

The minimum we would schedule. Thresholds are our defaults.

Checks to schedule for a legacy system integration
RouteCheckAlert when
Every routeDaily count and one amount total, against the legacy system's own report. Program calls and bot writes also get a read-backAny difference nobody has explained by the next day
Change trackingHours since the last successful run, against the retention periodMore than half the retention period has passed
CDClog_reuse_wait_desc, sys.dm_cdc_errors and latencyREPLICATION persists, any new error appears, or latency passes your freshness target
CDCsys.sp_cdc_get_ddl_history for each capture instanceAny new row: the vendor changed a captured table
Rowversion pollingRows returned per runA run returns several times its usual count
Exports, restored copies and emailArrival by the deadline, and the newest record dateNothing by the deadline, a stale copy, or 432 and 554 replies in the sending log
After every vendor upgradeRerun the inventory queries. Check is_tracked_by_cdc in sys.tables, sys.change_tracking_tables and your triggersAnything you enabled is gone or disabled

If you need data out of a system with no API and aren't sure which of these routes it allows, bring the vendor's answers to the email above (or the lack of them) and the output of the inventory queries. We'd start with the edition, the Agent status and whether the tables you need have primary keys, because those three decide which routes exist. Our work on improving and rebuilding existing systems starts from that assessment.

Build with Adamant Code

Plan the next change to your existing system

Bring the workflow that depends on it, a recent failure and the records that must survive the change. We will discuss what to keep, improve or replace.

Explore software modernization
Discuss your existing software