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.

The OOP Revolution in PLC Programming

For decades, PLC programming meant flat function blocks and global variables. Then platforms like CODESYS 3.5, Beckhoff TwinCAT 3, and Siemens TIA Portal v16+ introduced Object-Oriented Programming to Structured Text. Suddenly, concepts from software engineering — methods, interfaces, inheritance, polymorphism — became available to automation engineers.

This isn't academic theory. OOP in PLCs solves real 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 existing logic? OOP gives you the tools.

🧩 Mental Model: The Vending Machine

Before diving into syntax, think about a vending machine:

  • INTERFACE = the buttons and coin slot (what you interact with)
  • FUNCTION_BLOCK = the internal mechanism (how it works)
  • METHOD = individual actions: InsertCoin(), SelectProduct(), Dispense()
  • PROPERTY = readable status: CurrentBalance, IsEmpty
  • INHERITANCE = a snack machine and a drink machine share the same coin handling but dispense differently
  • The key insight: the user only sees the interface. The internal mechanism can be completely different between a snack machine and a drink machine, but you interact with both the same way. That's polymorphism.

    Methods: Functions Inside Function Blocks

    A method is a function that belongs to a function block. It can access all 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/SET

    Properties look like variables from the outside but execute code internally. They replace the need for getter/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 specifying how. It's a contract: "Any FB that implements this interface must provide these 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

    The Power: Different Implementations, Same 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 in Action

    Now the magic — 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);

    Add a new motor type (servo, stepper, pneumatic) and you never touch FC_StartConveyor. Just implement I_Driveable. This is the Open/Closed Principle: open for extension, closed for modification.

    🧬 Inheritance: Extending Behavior

    Inheritance lets you create specialized versions of existing function blocks:

    // 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%

    📐 Design Pattern: Strategy via Interface

    The Strategy Pattern lets you swap algorithms at runtime. Real-world example — a filling machine that supports different dosing methods:

    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 NOT to Use OOP

    OOP adds complexity. Use it when you get real benefit:

    | Use OOP When | Don't Use OOP When | | Multiple implementations of same concept | Simple, one-off logic | | Building reusable libraries | Small projects (< 500 I/O) | | Team of 3+ programmers | Solo developer, single machine | | Multiple machine variants | Logic will never change | | Swappable algorithms needed | Fixed calculation |

    The test: If you can't name at least two future implementations of your interface, you probably don't need an interface.

    Summary

    | Concept | Purpose | Syntax | | Method | Function inside an FB | METHOD name : ReturnType | | Property | Controlled variable access | PROPERTY name : Type / GET / SET | | Interface | Contract for capabilities | INTERFACE I_Name / IMPLEMENTS | | Inheritance | Extend existing FB | EXTENDS FB_Base | | Polymorphism | Same code, different behavior | Pass I_Interface as parameter | | Strategy Pattern | Swappable algorithms | Interface + multiple implementations |

    OOP in PLCs isn't about making code look like Java. It's about building automation libraries that survive machine variants, product changes, and team turnover. Start with interfaces for your equipment types, and the rest follows naturally.