I was staring at a TIA Portal project that had grown… again.
More FBs, more flags, more duplicated logic. Nothing was “wrong” — the machine worked — but it was getting harder to reason about.
At some point I caught myself thinking:
"I'm already designing objects. I'm just not admitting it."
This post is a practical, honest example of applying object‑oriented design in Siemens TIA Portal using PLC‑native constructs — and then showing the same design implemented in real C#.
0) The demo repo behind this post
If you want the “real thing” (not just snippets), this article is based on a small, dual implementation demo in my GitHub repo: CylDemo_DualRepo. It contains the same architecture expressed twice:
- Siemens TIA Portal (SCL): PLC-native constructs (FB + instance DB + UDTs), no “fake inheritance”.
- C#: the equivalent model using real OOP constructs (interfaces/classes), so you can compare concepts 1:1.
Reading this post while having the repo open in another tab is the fastest way to connect the dots.
1) Two screenshots that explain the architecture
Sometimes the architecture is clearer in the project tree than in paragraphs. These two screenshots are the “map” for everything that follows:
1) First: what OOP is not in PLCs
In high-level languages, “OOP” often means: dynamic allocation, deep inheritance trees, virtual dispatch, exceptions, and a runtime that happily hides details from you.
On a PLC, you pay for every abstraction with scan time, memory, and sometimes diagnosability. The PLC must remain predictable: same cycle, same ordering, clear data ownership.
So the approach here is pragmatic: we borrow the parts of OOP that help us structure automation software—encapsulation, clear interfaces, and composition—and we avoid the parts that fight the PLC execution model.
- No magic runtime: you still execute deterministically in a cyclic scan (OB1).
- No implicit object lifetime: a Function Block instance is a fixed memory area (instance DB).
- Behavior is explicit: you can always point to “where” a decision is made and “which” data it writes.
OOP is not about keywords. In TIA Portal we don’t have class inheritance, virtual methods, or dynamic dispatch in the “C# sense”. Trying to fake those features usually adds complexity without improving clarity.
So the right question is not “does TIA support OOP?”. The right question is:
Can we apply OOP design principles using PLC-native constructs?
The answer is yes — if we focus on interfaces, encapsulation, and composition.
2) The core idea: same interface, different behavior
This is the automation-friendly version of polymorphism: you don’t need a vtable to benefit from it. You just need a consistent external contract and the freedom to implement it differently.
In TIA, the “polymorphism” is achieved by calling different FB instances behind the same boundary. A typical OB1 wiring looks like this:
// OB1 (or an equipment program block)
#Cmd.Enable := #HMI_Enable;
#Cmd.Extend := #HMI_Extend;
#Cmd.Retract := #HMI_Retract;
// The rest of the program only sees the shared interface:
#Cylinder(Cmd := #Cmd, Status => #St);
The key is that #Cylinder exposes the same Cmd/Status regardless of whether it internally uses CylA, CylB, or a future variant.
Practically, this means your HMI, your higher-level sequencing (SFC/step logic), or your “machine supervisor” FB can command “a cylinder” without caring whether it is pneumatic, electric, simulated, or even replaced by a virtual twin.
In the demo repository, both implementations can be driven with the same set of commands (extend/retract) and both expose the same statuses (extended/retracted/busy/fault). The internal logic is where they diverge.
I picked a deliberately simple system: two cylinders.
- CylA: pneumatic — discrete behavior, depends on a permissive (
PressureOk). - CylB: electric — internally models position (0..100), moves scan-by-scan.
Externally, I want to treat them the same. That implies a shared contract:
- a command interface
- a status interface
3) Interfaces live in data (UDTs), not in behavior
In TIA Portal, we don’t declare interfaces the way we do in C#—but we can get 80% of the value by defining a data contract that every implementation respects.
A good rule: keep interface UDTs small, stable, and boring. They should describe what the outside world needs, not what the implementation happens to store internally.
- Command UDT: edge-driven requests, mode bits, setpoints.
- Status UDT: stable feedback, busy flags, diagnostics.
- Versioning: prefer adding fields to the end and keeping defaults safe.
Once your interface is a UDT, you can pass it around, log it, map it to HMI tags, or mirror it to OPC UA without exposing internal state.
In C#, interfaces are explicit language features. In PLCs, the closest equivalent is a UDT used as a data contract.
3.1 Command interface (TIA Portal — SCL)
TYPE UDT_IActCmd :
STRUCT
Enable : BOOL;
Extend : BOOL;
Retract : BOOL;
END_STRUCT
END_TYPE
3.2 Status interface (TIA Portal — SCL)
TYPE UDT_IActStatus :
STRUCT
Extended : BOOL;
Retracted : BOOL;
END_STRUCT
END_TYPE
No logic. No private state. Just a contract. This is the PLC version of:
public interface IActCmd
{
bool Enable { get; }
bool Extend { get; }
bool Retract { get; }
}
public interface IActStatus
{
bool Extended { get; }
bool Retracted { get; }
}
4) Function Blocks as “classes” (class-like structure)
Think of an FB as a class template and its instance DB as the object’s memory. That gives you:
- Encapsulation: statics are private by default (if you keep them inside the FB).
- Instance-specific state: each instance DB holds its own timers, latches, and internal variables.
- Constructor-like init: a
firstScanorInitsection sets defaults once.
A practical pattern is: keep external inputs/outputs in the interface UDTs, and keep internal workings in a separate implementation UDT or statics section. The outside world gets a clean surface; the inside can evolve.
Each cylinder is implemented as a Function Block:
FB_CylA(pneumatic)FB_CylB(electric)
The important part is not the FB keyword — it’s the idea that each FB:
- owns its internal state
- decides its behavior
- publishes status through the interface
To make this explicit (and easy to teach), I structure each FB like a class: constructor + methods, using REGION blocks and a firstScan flag.
4.1 CylA — pneumatic implementation (SCL)
REGION Constructor
IF #firstScan THEN
#isExtended := FALSE;
#isRetracted := TRUE;
#firstScan := FALSE;
END_IF;
END_REGION
REGION ExecuteCommands
#valveExtend := FALSE;
#valveRetract := FALSE;
IF #Cmd.Enable AND #Inp.PressureOk THEN
IF #Cmd.Extend THEN
#valveExtend := TRUE;
ELSIF #Cmd.Retract THEN
#valveRetract := TRUE;
END_IF;
END_IF;
END_REGION
REGION UpdateInternalState
IF #valveExtend THEN
#isExtended := TRUE;
#isRetracted := FALSE;
ELSIF #valveRetract THEN
#isExtended := FALSE;
#isRetracted := TRUE;
END_IF;
END_REGION
REGION PublishStatus
#Status.Extended := #isExtended;
#Status.Retracted := #isRetracted;
END_REGION
Same idea in C# (real OOP): private fields + Update() + public properties.
public sealed class CylA : IActStatus
{
private bool _isExtended;
private bool _isRetracted = true;
public void Update(IActCmd cmd, bool pressureOk)
{
if (!cmd.Enable || !pressureOk) return;
if (cmd.Extend) { _isExtended = true; _isRetracted = false; }
else if (cmd.Retract) { _isExtended = false; _isRetracted = true; }
}
public bool Extended => _isExtended;
public bool Retracted => _isRetracted;
}
5) Different internal logic, same interface (CylB)
CylB is intentionally “different inside” while remaining identical from the outside. Internally it models position and motion scan-by-scan, but it still exposes a binary status:
REGION ExecuteCommands
IF #Cmd.Enable THEN
IF #Cmd.Extend THEN
#target := 100;
ELSIF #Cmd.Retract THEN
#target := 0;
END_IF;
END_IF;
END_REGION
REGION UpdateMotion
IF NOT #Cmd.Enable THEN
#target := #position; // stop when disabled
END_IF;
IF #position < #target THEN
#position := #position + #step;
IF #position > #target THEN #position := #target; END_IF;
ELSIF #position > #target THEN
#position := #position - #step;
IF #position < #target THEN #position := #target; END_IF;
END_IF;
END_REGION
REGION PublishStatus
#Status.Extended := (#position = 100);
#Status.Retracted := (#position = 0);
END_REGION
And the equivalent C# implementation:
public sealed class CylB : IActStatus
{
private int _position = 0;
private int _target = 0;
private int _step = 1;
public void Update(IActCmd cmd, int speedPerScan)
{
_step = FC_ClampInt.ClampInt(speedPerScan, 1, 100);
if (cmd.Enable)
{
if (cmd.Extend) _target = 100;
else if (cmd.Retract) _target = 0;
}
else
{
_target = _position; // stop when disabled
}
if (_position < _target) _position = Math.Min(_position + _step, _target);
else if (_position > _target) _position = Math.Max(_position - _step, _target);
}
public bool Extended => _position == 100;
public bool Retracted => _position == 0;
}
6) The container: composition over inheritance (FB_Cylinder)
Instead of inheriting from a base class, we compose a cylinder from smaller, focused blocks:
- An “API layer” that exposes the common interface (command + status)
- An “implementation layer” that actually moves valves or updates position
- Optional helpers (interlocks, debounce, timeouts, fault handling)
In practice, this often becomes a Strategy-like pattern: the container FB selects which implementation to execute based on configuration (type, hardware present, simulation mode).
The big win: you can add a third cylinder type (e.g., servo over PROFINET) without rewriting the sequencing logic that uses the interface.
FB_Cylinder is not a base class. It is an orchestrator that owns two objects and executes them each scan (multi-instance composition).
#CylA(Cmd := #CmdCylA, Inp := #CylA_Params, Status => #CylA_Status);
#CylB(Cmd := #CmdCylB, Cfg := #CylB_Params, Status => #CylB_Status);
If you need to select an implementation at runtime (e.g., based on configuration), keep the decision outside the concrete blocks. The container decides once, then delegates each scan:
// Inside FB_Cylinder (sketch)
IF #Cfg.UseAnalog THEN
#CylB(Cmd := #Cmd, Cfg := #CfgB, Status => #Status);
ELSE
#CylA(Cmd := #Cmd, Inp := #CfgA, Status => #Status);
END_IF;
This keeps the rest of the program stable: the outside world still talks to FB_Cylinder, not to CylA/CylB.
In C# this is a regular container object:
public sealed class FB_Cylinder
{
private readonly CylA _cylA = new();
private readonly CylB _cylB = new();
public void Update(/* inputs */)
{
// build commands
// execute both objects
}
}
7) FCs as “static methods” (shared behavior)
FCs are a great place for behavior that is:
- Stateless: output depends only on inputs
- Reusable: shared by multiple FBs
- Easy to test: deterministic and side-effect free
Examples: edge detection utilities, clamp/saturation functions, standard timeout evaluation, and small policy decisions (“is it safe to move?”).
If an FC starts accumulating memory, it’s a sign it should become an FB (so the state is explicit and instance-specific).
Reusable rules live in stateless FCs. Example: clamping a configuration value.
FUNCTION FC_ClampInt : INT
VAR_INPUT
Value : INT;
Min : INT;
Max : INT;
END_VAR
VAR_TEMP v : INT;
v := Value;
IF v < Min THEN v := Min; END_IF;
IF v > Max THEN v := Max; END_IF;
FC_ClampInt := v;
public static class FC_ClampInt
{
public static int ClampInt(int value, int min, int max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
}
8) About inheritance (the honest version)
Classic inheritance is not the main tool in PLC-land. Even in “real” OOP projects, deep inheritance tends to hurt readability and testability.
PLC-friendly alternatives:
- Composition: build complex behavior from smaller FBs (multi-instance).
- Protocols via UDTs: stable contracts between layers.
- State machines: explicit modes with clear transitions (idle, moving, fault).
- Templates by copy: duplicate an FB and keep the interface identical when needed (controlled duplication beats tangled inheritance).
There is no inheritance in the PLC implementation. TIA Portal doesn’t provide class inheritance between FBs in the way C# does.
But the design can still be understood as conceptual inheritance:
- same interface
- same usage from the outside
- different internal behavior
Inheritance in design, not in PLC syntax.
9) Closing
If you take one idea from this article, let it be this: design around interfaces and ownership. If each device “owns” its state and exposes only what is needed, your project scales better—whether you add devices, variants, or simulation later.
The demo repo keeps a 1:1 mental mapping with C#: FBs ≈ classes, UDTs ≈ interfaces, FCs ≈ static helpers. That mapping is a teaching tool, but the underlying goal is practical: readable automation software that survives the next commissioning.
Next steps you can try in your own projects:
- Add a Simulation implementation that ignores hardware and updates status from a model.
- Introduce a standardized fault structure (code, text id, timestamp) in the status interface.
- Write a small “supervisor” FB that sequences devices purely through their command/status UDTs.
PLCs don’t prevent good software design. They simply force you to be explicit.
If you design with:
- data interfaces (UDTs)
- encapsulated behavior (FBs)
- composition (multi-instances)
- stateless shared rules (FCs)
…you get a structure that is clean, scalable, and maps naturally to “real OOP” in high-level languages.