PLC Object-Oriented Programming: Methods, Interfaces & Inheritance in Structured Text

Modern PLCs support OOP — and it changes everything. Learn how methods, interfaces, inheritance, and polymorphism let you build automation libraries that scale across machines and projects.

What OOP brings to Structured Text

For decades, PLC programming meant flat function blocks and global variables. Then platforms such as CODESYS 3.5 and Beckhoff TwinCAT 3 added object-oriented programming (OOP) to Structured Text, and automation engineers got methods, interfaces, inheritance and polymorphism from software engineering.

OOP in a PLC solves practical problems. How do you write a motor control library that handles DOL starters, VFDs and servo drives with the same calling code? How do you add a new sensor type without modifying the existing logic? The features below are the tools for both.

The listings below are vendor syntax in the CODESYS and TwinCAT style (METHOD, PROPERTY, INTERFACE, IMPLEMENTS, EXTENDS), shown for reading rather than for pasting into the site's editor.

A mental model: the vending machine

Before the syntax, picture a vending machine. The INTERFACE is the buttons and the coin slot, the part you interact with. The FUNCTION_BLOCK is the mechanism inside that does the work. Each METHOD is one action, such as InsertCoin(), SelectProduct() or Dispense(), and each PROPERTY is a status you can read, such as CurrentBalance or IsEmpty. INHERITANCE is a snack machine and a drink machine that share the same coin handling but dispense differently.

The customer only ever sees the interface. The mechanism inside a snack machine can be completely different from the one in a drink machine, yet you use both the same way, and that is polymorphism.

Methods: functions inside a function block

A method is a function that belongs to a function block, and it can access all of the FB's internal variables:

FUNCTION_BLOCK FB_Actuator
VAR
    _position    : REAL := 0.0;
    _speed       : REAL := 0.0;
    _enabled     : BOOL := FALSE;
    _errorCode   : DINT := 0;
    _targetPos   : REAL := 0.0;
END_VAR

// METHOD: Enable — turns on the actuator
METHOD Enable : BOOL
VAR_INPUT
    safetyOK : BOOL;
END_VAR

IF NOT safetyOK THEN
    _errorCode := 1001;
    Enable := FALSE;
    RETURN;
END_IF;

_enabled := TRUE;
_errorCode := 0;
Enable := TRUE;    // Return success

// METHOD: MoveTo — commands a position
METHOD MoveTo : BOOL
VAR_INPUT
    targetPosition : REAL;
    moveSpeed      : REAL;
END_VAR

IF NOT _enabled THEN
    MoveTo := FALSE;
    RETURN;
END_IF;

IF targetPosition < 0.0 OR targetPosition > 1000.0 THEN
    _errorCode := 1002;   // Out of range
    MoveTo := FALSE;
    RETURN;
END_IF;

_targetPos := targetPosition;
_speed := moveSpeed;
MoveTo := TRUE;

// METHOD: GetStatus — returns current state
METHOD GetStatus : INT
// 0=Disabled, 1=Idle, 2=Moving, 3=AtPosition, 4=Error

IF _errorCode <> 0 THEN
    GetStatus := 4;
ELSIF NOT _enabled THEN
    GetStatus := 0;
ELSIF ABS(_position - _targetPos) < 0.1 THEN
    GetStatus := 3;
ELSIF _speed > 0.0 THEN
    GetStatus := 2;
ELSE
    GetStatus := 1;
END_IF;

Calling methods

PROGRAM Main
VAR
    gripper : FB_Actuator;
    moveOK  : BOOL;
    status  : INT;
END_VAR

// Enable with safety check
IF gripper.Enable(safetyOK := SafetyCircuitOK) THEN
    // Move to pick position
    moveOK := gripper.MoveTo(targetPosition := 150.0, moveSpeed := 200.0);
END_IF;

// Check status
status := gripper.GetStatus();

Properties: controlled access with GET and SET

From the outside a property looks like a variable, but reading or writing it runs code. That removes the need for separate getter and setter methods:

FUNCTION_BLOCK FB_TemperatureZone
VAR
    _setpoint    : REAL := 20.0;
    _actualTemp  : REAL;
    _minLimit    : REAL := -10.0;
    _maxLimit    : REAL := 300.0;
    _spChanged   : BOOL;
END_VAR

// PROPERTY: Setpoint (read/write with validation)
PROPERTY Setpoint : REAL

// GET accessor — called when reading the property
GET
    Setpoint := _setpoint;
END_GET

// SET accessor — called when writing the property
SET
    // Validate before accepting
    IF Setpoint >= _minLimit AND Setpoint <= _maxLimit THEN
        _setpoint := Setpoint;
        _spChanged := TRUE;
    END_IF;
    // Invalid values are silently rejected — the setpoint doesn't change
END_SET

// PROPERTY: ActualTemp (read-only — no SET accessor)
PROPERTY ActualTemp : REAL

GET
    ActualTemp := _actualTemp;
END_GET
// No SET = read-only property

// Usage — looks like a simple variable access
PROGRAM Main
VAR
    zone1 : FB_TemperatureZone;
END_VAR

zone1.Setpoint := 85.0;           // Calls SET — validates internally
currentTemp := zone1.ActualTemp;   // Calls GET — read-only access

zone1.Setpoint := 999.0;          // Rejected silently — stays at 85.0

Interfaces: the contract

An INTERFACE defines what an object can do without saying how it does it. It works as a contract: any FB that implements the interface must provide its methods and properties.

INTERFACE I_Driveable
    // Any FB implementing this must provide:
    METHOD Enable : BOOL
    VAR_INPUT
        safetyOK : BOOL;
    END_VAR
    END_METHOD

    METHOD Disable : BOOL
    END_METHOD

    METHOD SetSpeed : BOOL
    VAR_INPUT
        speedPercent : REAL;
    END_VAR
    END_METHOD

    PROPERTY IsRunning : BOOL   // GET only
    PROPERTY ActualSpeed : REAL // GET only
    PROPERTY HasError : BOOL    // GET only
END_INTERFACE

Two implementations of one interface

// Implementation 1: Simple DOL motor
FUNCTION_BLOCK FB_Motor_DOL IMPLEMENTS I_Driveable
VAR
    _contactor   : BOOL;
    _overload    : BOOL;
    _running     : BOOL;
END_VAR

METHOD Enable : BOOL
VAR_INPUT safetyOK : BOOL; END_VAR
    IF safetyOK AND NOT _overload THEN
        _contactor := TRUE;
        _running := TRUE;
        Enable := TRUE;
    END_IF;
END_METHOD

METHOD SetSpeed : BOOL
VAR_INPUT speedPercent : REAL; END_VAR
    // DOL motor is either on or off — ignore speed
    SetSpeed := _running;
END_METHOD

PROPERTY IsRunning : BOOL
GET IsRunning := _running; END_GET

// Implementation 2: VFD-controlled motor
FUNCTION_BLOCK FB_Motor_VFD IMPLEMENTS I_Driveable
VAR
    _vfdRunCmd   : BOOL;
    _vfdSpeedRef : REAL;
    _vfdFeedback : REAL;
    _vfdFaulted  : BOOL;
END_VAR

METHOD Enable : BOOL
VAR_INPUT safetyOK : BOOL; END_VAR
    IF safetyOK AND NOT _vfdFaulted THEN
        _vfdRunCmd := TRUE;
        Enable := TRUE;
    END_IF;
END_METHOD

METHOD SetSpeed : BOOL
VAR_INPUT speedPercent : REAL; END_VAR
    IF speedPercent >= 0.0 AND speedPercent <= 100.0 THEN
        _vfdSpeedRef := speedPercent;
        SetSpeed := TRUE;
    ELSE
        SetSpeed := FALSE;
    END_IF;
END_METHOD

PROPERTY IsRunning : BOOL
GET IsRunning := _vfdRunCmd AND NOT _vfdFaulted; END_GET

PROPERTY ActualSpeed : REAL
GET ActualSpeed := _vfdFeedback; END_GET

Polymorphism: one function for every drive

Now you can write code that works with any driveable device:

// This function doesn't know or care if it's a DOL, VFD, or servo
FUNCTION FC_StartConveyor : BOOL
VAR_INPUT
    drive      : I_Driveable;     // Accept ANY implementation
    targetSpeed: REAL;
END_VAR

IF drive.HasError THEN
    FC_StartConveyor := FALSE;
    RETURN;
END_IF;

IF drive.Enable(safetyOK := TRUE) THEN
    FC_StartConveyor := drive.SetSpeed(speedPercent := targetSpeed);
END_IF;

// Usage — same function, different hardware
PROGRAM Main
VAR
    conveyorA : FB_Motor_DOL;    // Simple starter
    conveyorB : FB_Motor_VFD;    // Variable speed
    result    : BOOL;
END_VAR

// Same function call for completely different hardware!
result := FC_StartConveyor(drive := conveyorA, targetSpeed := 100.0);
result := FC_StartConveyor(drive := conveyorB, targetSpeed := 75.0);

When you add a new motor type (servo, stepper, pneumatic), you implement I_Driveable for it and leave FC_StartConveyor untouched. Software engineers call this the Open/Closed Principle: open for extension, closed for modification.

Inheritance: extending a function block

Inheritance lets you build a specialised version of an existing function block:

// Base class: generic valve
FUNCTION_BLOCK FB_Valve_Base
VAR
    _isOpen      : BOOL := FALSE;
    _isClosed    : BOOL := TRUE;
    _faulted     : BOOL := FALSE;
    _openCmd     : BOOL := FALSE;
    _closeCmd    : BOOL := FALSE;
END_VAR

METHOD Open : BOOL
    IF NOT _faulted THEN
        _openCmd := TRUE;
        _closeCmd := FALSE;
        Open := TRUE;
    END_IF;
END_METHOD

METHOD Close : BOOL
    _closeCmd := TRUE;
    _openCmd := FALSE;
    Close := TRUE;
END_METHOD

// Derived class: adds modulating control
FUNCTION_BLOCK FB_Valve_Modulating EXTENDS FB_Valve_Base
VAR
    _positionSP  : REAL := 0.0;
    _positionPV  : REAL := 0.0;
    _deadband    : REAL := 1.0;
END_VAR

// NEW method — only on modulating valves
METHOD SetPosition : BOOL
VAR_INPUT
    position : REAL;    // 0–100%
END_VAR
    IF position >= 0.0 AND position <= 100.0 AND NOT _faulted THEN
        _positionSP := position;
        SetPosition := TRUE;
    END_IF;
END_METHOD

// OVERRIDE: Open now means "go to 100%"
METHOD Open : BOOL
    _positionSP := 100.0;
    _openCmd := TRUE;
    _closeCmd := FALSE;
    Open := NOT _faulted;
END_METHOD

// Usage
VAR
    shutoffValve : FB_Valve_Base;         // On/off only
    controlValve : FB_Valve_Modulating;   // Position control
END_VAR

shutoffValve.Open();                       // Simple open
controlValve.SetPosition(position := 65.0); // Modulated to 65%
controlValve.Open();                       // Opens to 100%

The strategy pattern through an interface

The strategy pattern lets you swap algorithms at runtime. A filling machine that supports different dosing methods is a good example:

INTERFACE I_DosingStrategy
    METHOD CalculateDose : REAL
    VAR_INPUT
        targetWeight : REAL;
        currentWeight: REAL;
        flowRate     : REAL;
    END_VAR
    END_METHOD

    PROPERTY Name : STRING  // Which strategy is active
END_INTERFACE

// Strategy 1: Simple cutoff
FUNCTION_BLOCK FB_Dosing_SimpleCutoff IMPLEMENTS I_DosingStrategy
METHOD CalculateDose : REAL
VAR_INPUT
    targetWeight : REAL;
    currentWeight: REAL;
    flowRate     : REAL;
END_VAR
    IF currentWeight >= targetWeight THEN
        CalculateDose := 0.0;     // Stop filling
    ELSE
        CalculateDose := 100.0;   // Full speed until target
    END_IF;
END_METHOD
PROPERTY Name : STRING
GET Name := 'Simple Cutoff'; END_GET

// Strategy 2: Predictive with preact
FUNCTION_BLOCK FB_Dosing_Predictive IMPLEMENTS I_DosingStrategy
VAR
    _preactWeight : REAL;
END_VAR
METHOD CalculateDose : REAL
VAR_INPUT
    targetWeight : REAL;
    currentWeight: REAL;
    flowRate     : REAL;
END_VAR
    // Calculate preact point: material-in-flight compensation
    _preactWeight := flowRate * 0.3;   // 300ms valve close time
    
    IF currentWeight >= (targetWeight - 0.5) THEN
        CalculateDose := 0.0;          // Final cutoff
    ELSIF currentWeight >= (targetWeight - _preactWeight) THEN
        CalculateDose := 20.0;         // Dribble feed
    ELSE
        CalculateDose := 100.0;        // Fast fill
    END_IF;
END_METHOD
PROPERTY Name : STRING
GET Name := 'Predictive Preact'; END_GET

// The filler uses whichever strategy is assigned
FUNCTION_BLOCK FB_FillingMachine
VAR
    _strategy   : I_DosingStrategy;   // Interface reference
    _valveCmd   : REAL;
END_VAR

METHOD SetStrategy : BOOL
VAR_INPUT
    strategy : I_DosingStrategy;
END_VAR
    _strategy := strategy;
    SetStrategy := (_strategy <> 0);   // Non-null check
END_METHOD

METHOD Execute : REAL
VAR_INPUT
    targetWt : REAL;
    currentWt: REAL;
    flowRate : REAL;
END_VAR
    IF _strategy <> 0 THEN
        _valveCmd := _strategy.CalculateDose(
            targetWeight := targetWt,
            currentWeight := currentWt,
            flowRate := flowRate
        );
    END_IF;
    Execute := _valveCmd;
END_METHOD

// Runtime strategy swap — change dosing algorithm without modifying the filler
PROGRAM Main
VAR
    filler    : FB_FillingMachine;
    simple    : FB_Dosing_SimpleCutoff;
    predictive: FB_Dosing_Predictive;
    output    : REAL;
END_VAR

// Use simple mode for rough fills
filler.SetStrategy(strategy := simple);
output := filler.Execute(targetWt:=50.0, currentWt:=PV_Weight, flowRate:=PV_Flow);

// Switch to predictive for precision fills — no code change in the filler!
filler.SetStrategy(strategy := predictive);
output := filler.Execute(targetWt:=50.0, currentWt:=PV_Weight, flowRate:=PV_Flow);

When to leave OOP out

OOP adds complexity, so use it only where it pays you back:

Use OOP WhenDon't Use OOP When
Multiple implementations of same conceptSimple, one-off logic
Building reusable librariesSmall projects (< 500 I/O)
Team of 3+ programmersSolo developer, single machine
Multiple machine variantsLogic will never change
Swappable algorithms neededFixed calculation
A quick test: if you can't name at least two future implementations of an interface, you probably don't need the interface.

Syntax at a glance

ConceptPurposeSyntax
MethodFunction inside an FBMETHOD name : ReturnType
PropertyControlled variable accessPROPERTY name : Type / GET / SET
InterfaceContract for capabilitiesINTERFACE I_Name / IMPLEMENTS
InheritanceExtend existing FBEXTENDS FB_Base
PolymorphismSame code, different behaviorPass I_Interface as parameter
Strategy PatternSwappable algorithmsInterface + multiple implementations
The point of OOP in a PLC is an automation library that survives machine variants, product changes and team turnover. Start with interfaces for your equipment types; methods, properties and inheritance then follow from what each implementation needs.