Is Delphi business logic trapped in your forms?
Find and separate Delphi business logic hidden in VCL forms, data-aware controls, dataset events, and transaction code before rewriting the UI.

Delphi forms are often executable specifications wearing a user interface. A button click calculates discounts, a TDBEdit.OnExit normalizes an account code, BeforePost rejects a closed period, and a grid event changes what the next query returns. Treat those handlers as disposable presentation code and the replacement will look finished while quietly changing the business.
A straight port makes that risk worse. Recreating every form in a browser or another desktop toolkit preserves the old boundaries, then charges you to rediscover their hidden coupling in a less forgiving event model. The safer route is to identify observable behavior, extract rules behind explicit interfaces, and let the old VCL application and the new system call the same conceptual operations during the transition.
How did business logic end up in Delphi forms?
Business logic ended up in Delphi forms because the VCL made the shortest path to working software extremely short. Drop a dataset, a TDataSource, some data-aware controls, and a button onto a form, then put the decision beside the event that needs it. That choice was rational when one developer owned the application and users sat near the database. Years of change turned proximity into architecture.
The form class then accumulated several different responsibilities. It reads control state, interprets user intent, applies policy, starts transactions, updates datasets, formats messages, and decides which screen opens next. The .dfm file adds another layer because property values and component wiring change runtime behavior without appearing in the Pascal method you are reading.
This is why line counts understate the migration. A 250-line unit can depend on dozens of inherited properties, persistent fields, actions, shared data modules, and event assignments stored in the DFM. An apparently empty TDBEdit still writes through TDataSource into a dataset buffer. Its behavior depends on dataset state, field events, edit masks, and whatever BeforePost does later.
Do not classify all event code as business logic. Showing a dialog, changing focus, and resizing columns belong to presentation. Deciding that an invoice cannot post after a period closes is a rule. Translating a customer tier into a discount is a calculation. Calling a stored procedure may be application orchestration or data access, depending on what contract the procedure exposes. The distinction matters because each kind of code needs a different destination.
One useful test is to remove the form mentally. If the decision must remain true for an import, an API request, or a batch job, it is business logic. If the behavior only helps a person operate this particular screen, it can remain in the UI. If it coordinates a use case but contains no rule itself, it belongs in an application service.
The form file is only half the program
You need an inventory of executable behavior before extracting anything, and that inventory must include Pascal, DFM resources, inherited forms, and database objects. Searching only for click handlers misses automatic edits and lifecycle events. Reading only the DFM misses handlers assigned at runtime.
Start with a mechanical map. For each form, record every component event, dataset event, action, timer, message handler, and call that crosses into another unit. Include OnCreate, OnShow, OnCloseQuery, OnChange, OnExit, OnClick, OnExecute, BeforeEdit, BeforePost, AfterPost, OnCalcFields, and exception handlers. Search for assignments such as Button.OnClick := because applications sometimes rewire behavior after construction.
Then add the implicit paths. Record which controls point to each TDataSource, which dataset each source exposes, whether AutoEdit is enabled, and which persistent TField objects have validation or change events. Embarcadero's TDataSource documentation describes the component as the conduit between a dataset and data-aware controls. That modest word, conduit, is the warning: a keystroke can cross the UI boundary before any Save button runs.
Build a table with one row per observed behavior, not one row per method. These columns are enough to expose most traps:
Trigger Reads Writes Rule owner
btnPostClick invoice fields, role invoice status posting policy
AmountFieldValidate amount, currency record buffer money rule
CustomerDataChange current customer filter params query orchestration
The Evidence column forces precision. A handler name is not evidence. Capture the input values, dataset state, SQL calls, returned rows, messages, and final stored values. If a handler depends on the order in which VCL events fire, record the order. That sequence is part of the behavior until you prove that users and integrations cannot observe it.
Inherited forms deserve a separate pass. A child DFM can override a property while inheriting an event from an ancestor that lives in another project directory. The child unit may look harmless even though the base form opens datasets or changes permissions in OnShow. Expand the inheritance chain and record the effective component configuration for the built application, not merely the text stored in one file.
Actions also conceal reuse. One TAction.OnExecute can be triggered by a menu item, toolbar button, and shortcut, while OnUpdate decides availability from global state. If the new client copies only the visible button, keyboard users may lose a path and authorization may migrate into a cosmetic disabled flag. Treat execution permission as a rule at the command boundary; treat enabled state as a view of that decision.
Extract decisions before moving controls
Extract pure decisions and calculations first because they give you stable seams without disturbing the screen. Leave the event handler in place, but reduce it to collecting input, calling a rule, and rendering the result. The running application stays useful while the rule becomes callable without a form.
Suppose an order form calculates a credit decision inside btnApproveClick. The original handler reads fields, checks a customer flag, compares totals, updates controls, posts the dataset, and shows a message. Split the decision from those effects:
type
TApprovalInput = record
OrderTotal: Currency;
CreditLimit: Currency;
AccountOnHold: Boolean;
end;
TApprovalDecision = record
Allowed: Boolean;
ReasonCode: string;
end;
function DecideApproval(const Input: TApprovalInput): TApprovalDecision;
begin
if Input.AccountOnHold then
Exit(TApprovalDecision.Create(False, 'ACCOUNT_HOLD'));
if Input.OrderTotal > Input.CreditLimit then
Exit(TApprovalDecision.Create(False, 'LIMIT_EXCEEDED'));
Result := TApprovalDecision.Create(True, 'APPROVED');
end;
The exact record syntax may need adjustment for the Delphi version in the estate. The design is the point: the function accepts values, returns a decision, and knows nothing about TEdit, TField, modal results, or transactions. A unit test can cover it, a batch import can call an equivalent operation, and a target service can implement the same contract.
Do not move the old handler intact into a class named TOrderService. A method that accepts a form or reaches through a global data module still has UI coupling. Passing twenty controls as parameters merely hides that coupling in the signature. Define inputs in business terms, including enough context to make the decision deterministic.
Also resist extracting shared helpers too early. Two handlers that both calculate tax may differ because one handles credit notes and the other handles invoices dated before a rule change. First characterize both behaviors. Merge them only when the evidence says the difference is accidental.
Data-aware controls create an invisible write path
Data-aware controls require an explicit editing model in the replacement because they combine display, navigation, buffering, validation, and persistence. A browser input bound to JSON is not an equivalent substitute for TDBEdit connected through TDataSource to a live TDataSet.
The first hidden behavior is entry into edit mode. Embarcadero documents that TDataSource.AutoEdit defaults to true and calls the dataset's Edit method when a user attempts to modify a bound control. The original application may therefore lock a row, mark a record dirty, or enable Post and Cancel actions on the first keystroke. A new UI that waits for an explicit Save has a different concurrency model even if the fields look identical.
The second hidden behavior is buffering. A displayed field value may be neither the last committed database value nor the value another control sees after an event. Edit, Insert, Post, Cancel, cached updates, and provider settings define when changes become durable. Write these states down as a small state machine. For example: View permits navigation; Edit holds a local draft; Saving validates the draft and submits one command; Conflict preserves the user's draft while showing the newer server version.
The third behavior is validation placement. An edit mask checks characters during input. A field's OnValidate checks a complete value just before it enters the record buffer. BeforePost can inspect the whole record. Database constraints act later and may cover relationships no form knows about. Embarcadero's TField.OnValidate documentation explicitly notes that programmatic assignment bypasses EditMask, while OnValidate still checks the field before posting. That is a good reason to move durable rules below the widget level.
Use four buckets when relocating validation:
- Input assistance, such as formatting and immediate character feedback, stays in the client.
- Field invariants, such as an allowed code set, live in the domain operation and may also run in the client for speed.
- Cross-field and authorization rules run on the server or application boundary that owns the command.
- Referential and uniqueness constraints remain enforced by Postgres even when friendlier checks run earlier.
Duplicating a rule for immediate feedback is acceptable only when one implementation remains authoritative. The server must reject an invalid command regardless of what the client checked. Otherwise an import, integration, or stale client can bypass the business.
Master-detail binding adds another trap. Moving the master cursor can automatically change parameters and refresh detail rows. Users may read that as one coherent workspace, but the implementation relies on cursor position rather than an explicit identifier. The replacement should request details by master ID, keep selection state in the client, and decide what happens when the master changes while the detail contains an unsaved draft.
Calculated and lookup fields need an owner too. A calculated field used only for display belongs in a query projection or view model. If another rule reads it, move the calculation into the domain operation and test its inputs. A lookup field can hide a database round trip or stale cached value, so record whether the current behavior observes live data, data from opening time, or data refreshed by a particular event.
Dataset events are not a domain model
Moving datasets into a TDataModule improves organization, but it does not by itself separate business logic. Embarcadero describes TDataModule as a place to centralize nonvisual components and even permits business rules there. That advice solves form clutter. It does not create boundaries, explicit inputs, or independently testable use cases.
Dataset events often mix three jobs. BeforePost may validate an invariant, fill audit fields, and execute another query. AfterScroll may refresh a detail dataset and enable an action. OnCalcFields may compute a display value that another handler later treats as authoritative. Copying these events into a repository or ORM hook recreates the same ambiguity.
Classify each event by what causes it and what it guarantees. A domain operation should run because a caller requested ApproveOrder, not because a generic dataset happened to post. A repository should persist an approved order, not decide whether approval is allowed. A view model may calculate display text, but persisted totals should come from the rule that owns money calculations.
There is one awkward case: third-party code may call Post directly and depend on BeforePost to protect the record. Do not delete that guard during extraction. Put the rule in a callable unit, have BeforePost call it, and route new commands through the same rule. Remove the old event only after tracing shows that every write path uses the new boundary.
Global data modules need special care. A form may assume that dmMain.qryCustomer is already open, positioned on the same customer, and inside a transaction started elsewhere. That is shared mutable state. Capture those preconditions as explicit identifiers and transaction scopes. Passing CustomerId is safer than passing the current row of a dataset whose cursor another event can move.
Transactions must follow the use case
Transaction boundaries should surround a business operation, not a button handler or every dataset post. The old code may begin a transaction in one event, touch several datasets through nested calls, and commit in another. Splitting that sequence across HTTP requests can leave partial work that the desktop application never allowed.
Trace one successful operation and each meaningful failure. Record the SQL statements, stored procedure calls, generated identifiers, lock behavior, and commit or rollback point. Then name the operation in business language. ClosePeriod might update the period record, create ledger entries, and reject pending drafts. Those changes belong in one application command even if the VCL reaches them through three forms.
Define a request and result before choosing transport details:
{
"operation": "ApproveOrder",
"order_id": 4812,
"expected_version": 17,
"actor_id": 204,
"decision_input": {
"order_total": "1250.00",
"currency": "EUR"
}
}
A successful result should return the new version, the resulting status, and stable reason codes. A conflict should return the current version without silently overwriting it. The decimal value is a string here to avoid turning a currency decision into a floating-point accident in a TypeScript client.
Do not expose a generic UpdateOrder endpoint that accepts every column. It transfers the dataset abstraction across the network and invites callers to create states the form once prevented. Commands such as ApproveOrder, ReleaseHold, and ChangeDeliveryDate reveal intent and give each transaction a defensible boundary.
Stored procedures complicate ownership but not the method. If a procedure contains rules, characterize its inputs, outputs, mutations, and error behavior as part of the current system. Keep it behind an adapter first. Rewrite it only after parity tests cover the behavior, especially when Delphi code interprets vendor error codes or depends on trigger side effects.
Characterization tests are your first specification
Characterization tests should compare observable outcomes from the old application with outcomes from the extracted or rewritten operation. Unit tests written from remembered requirements are useful later, but they cannot tell you which undocumented behavior the business already relies on.
Capture representative production traffic where policy permits, remove or protect sensitive data, and turn each operation into a replayable case. For a desktop application, traffic includes more than network requests. Record initial database state or a stable fixture, user inputs, relevant permissions, invoked action, messages or reason codes, SQL effects, and final rows. Add cases for cancellation, duplicate clicks, stale records, nulls, rounding boundaries, and database failures.
A compact fixture can make review practical:
case: approve-order-over-limit
given:
order_id: 4812
order_total: "1250.00"
credit_limit: "1000.00"
account_on_hold: false
when: ApproveOrder
expect:
allowed: false
reason_code: LIMIT_EXCEEDED
order_status: DRAFT
committed_writes: 0
Run the case against a controlled instance of the Delphi path and the new path. Compare business results, persisted state, and relevant side effects. Do not compare incidental differences such as generated timestamps unless they affect a contract. Normalize database-generated identifiers when the identity itself is not meaningful.
Golden-master testing has limits. The old system can be wrong, and blindly preserving every defect freezes it. Tag discrepancies as expected parity, approved correction, or unresolved difference. An approved correction needs a named owner and a test for the intended behavior. Otherwise developers will call surprises fixes and reviewers will lose the ability to tell migration from redesign.
Time and locale deserve deliberate fixtures. Delphi applications often convert dates through workstation settings and round currency through database types, field types, and display formats at different points. Include end-of-day values, daylight-saving changes when timestamps matter, decimal halves, blank strings, and nulls. Assert stored values and decision codes instead of formatted labels unless the label itself is a contractual document field.
Test event suppression as well. Code may temporarily disable controls, detach an event, or set a loading flag to prevent recursive updates. The new operation should not reproduce those mechanical tricks, but its final outcome must match. A replay that records only the happy request and final row can miss duplicate side effects produced halfway through the sequence.
This is where CodeHero uses a parity harness against recorded production traffic. The useful principle does not depend on our platform: replacement is an evidence problem, and screen resemblance is weak evidence.
A straight UI port preserves the expensive boundary
A straight UI port is usually the most expensive route because it rebuilds screens before discovering the operations beneath them. Teams reproduce tabs, modal dialogs, grids, and navigation, then wire them to generic CRUD endpoints. Every hidden rule surfaces late as a UI bug, an API exception, or a disagreement about what Save used to mean.
The copied screen also carries desktop assumptions into a distributed system. The VCL application can hold a live dataset cursor, share a connection, and respond synchronously to field events. A web client faces latency, retries, concurrent edits, expired sessions, and requests that can arrive twice. Simulating a stateful dataset over HTTP produces chatty APIs and fragile client logic.
Pixel parity is therefore the wrong acceptance criterion. Preserve task parity and business parity. A user must still be able to approve the right order, see why a decision failed, recover from a conflict, and complete the job with the required information. The new screen can combine old dialogs or remove navigation steps if the operation and its controls remain clear.
There are cases where a thin UI port is sensible. If the immediate goal is operating-system compatibility, the database and integrations will remain unchanged, and the form contains little business code, a compatible desktop replacement can buy time. Treat it as containment with a stated lifetime. Do not call it architectural separation.
For most long-lived systems, define the target around commands, queries, and explicit state. Go services fit transaction-oriented application operations and database access. Rust makes sense for numeric kernels where exact behavior and performance deserve a narrow boundary. TypeScript clients should own interaction state and display logic, while Postgres enforces durable relational constraints. These are choices described by the supplied project context, not a prescription that every Delphi estate needs all four.
Cut over by operation, not by form
Cut over one business operation at a time because forms rarely match clean service boundaries. An order form may contain customer lookup, pricing, approval, printing, and payment actions. Replacing the whole form forces all five paths to become ready together and creates a large rollback unit.
Choose an operation with clear inputs, measurable outcomes, and limited shared state. Put an interface in front of the old implementation, then add the new implementation behind the same contract. The VCL form can call that boundary before the new client exists. This creates evidence that separation works without tying domain extraction to a visual rewrite.
A practical sequence has five parts:
- Record current behavior and failure cases for the selected operation.
- Extract its rule inputs and result codes while the form still owns presentation.
- Place persistence behind an adapter and define the transaction boundary.
- Replay parity cases against both implementations and classify every difference.
- Route a controlled set of calls to the new path, with an explicit rollback switch.
Avoid dual writes unless you can make them idempotent and reconcile them. Reading from both systems for comparison is safer than letting both mutate authoritative data. If shadow execution would trigger emails, payments, print jobs, or audit entries, replace those effects with recorders in the comparison environment.
Make rollback an operation-level routing decision. If the new approval path fails, send approval back to the old implementation without reverting unrelated customer searches that already moved. Keep database compatibility explicit while both paths run. A schema change that makes the old executable unable to read a row removes your rollback even when the feature flag still exists.
Observe business outcomes during cutover. Count reason codes, conflicts, cancellations, and completed operations, then compare their shapes with the recorded baseline. Raw error rates are too coarse: a new path can return HTTP success while approving orders the old rules rejected. Investigate changes in decisions before expanding traffic.
The hardest dependency should determine order. If approval depends on pricing, extract pricing first or keep it behind an adapter that both versions call. Do not migrate easy screens while leaving the central transaction as a final surprise. That produces visible progress and little risk reduction.
CodeHero reads the whole Delphi codebase, including the connected languages, and modernizes the architecture while holding behavior to the original. For a manual program, the same discipline applies: map globally, cut locally, and never infer safety from a compiled screen.
The replacement should make hidden state impossible
The target is ready when business decisions no longer depend on a control, current dataset row, form creation order, or implicit global transaction. You should be able to invoke an operation with serialized input, observe a stable result, and test it without constructing a window.
That standard exposes incomplete extractions. A service that reads Screen.ActiveForm, a repository that fires policy in a generic save hook, or a TypeScript client that calculates the authoritative invoice total still carries the old problem. The names changed, but the behavior remains hidden behind infrastructure events.
Keep a short boundary checklist in code review:
- Does the operation accept business values and identifiers rather than controls or dataset cursors?
- Can every durable rule run for UI, import, and API callers?
- Does one transaction cover the complete business change?
- Are concurrency and retry outcomes explicit?
- Can a recorded case prove parity without comparing pixels?
Some behavior will stay in the VCL shell during transition, and that is fine. Focus management, keyboard shortcuts, layout, and local draft presentation do not need premature abstraction. The line to defend is authority: presentation may propose and explain a change, but an application operation decides and commits it.
Do not begin by redrawing the largest form. Pick the operation inside it that causes the most expensive failure, capture its current evidence, and give it a name. Once that operation can run without the form, the rest of the modernization has a boundary it can build on.
FAQ
How can I tell whether a Delphi event handler contains business logic?
Imagine calling the same operation from an import or API without constructing the form. Decisions that must still hold are business rules; focus changes, dialogs, and layout remain presentation behavior.
Should I move form code into a TDataModule first?
A TDataModule can reduce form clutter, but it does not create a business boundary. Move rules into units with explicit value inputs and results, then let both the form and data module call them.
Are data-aware VCL controls safe to replace with ordinary web inputs?
Only after you model their hidden edit, buffer, validation, post, and cancel behavior. Similar fields on screen do not guarantee the same concurrency or persistence semantics.
Where should Delphi OnValidate rules go in a new system?
Put durable field invariants in the application or domain operation that every caller uses. The client may repeat a check for quick feedback, but it cannot be the authority.
How do I migrate logic from BeforePost safely?
Extract the rule into a callable unit and keep BeforePost calling it while old write paths remain. Remove the event only after tracing proves that every write enters through the new command boundary.
Why is a straight Delphi UI port so expensive?
It rebuilds screens before exposing the operations and implicit state underneath them. Teams then pay twice: once for visual reproduction and again when hidden rules force the new UI and API to change.
Do I need to preserve every bug for behavioral parity?
No. Classify each difference as required parity, an approved correction, or unresolved behavior. A correction needs an owner and a test so that migration work does not become unreviewed redesign.
Can unit tests replace recorded production cases?
Unit tests prove the rules you know to write down. Recorded cases expose event order, data shapes, and side effects that nobody remembered, so use both for different purposes.
Should the new API expose generic CRUD endpoints?
Avoid generic updates for rule-heavy records. Commands such as ApproveOrder express intent, define a transaction boundary, and prevent callers from assembling invalid states one field at a time.
What is the safest unit for a Delphi cutover?
Cut over a named business operation with clear inputs, outcomes, and rollback, even when it spans several forms. A whole form is usually too broad, while a single field handler is usually too narrow.