Skip to content
Home » Software Design » Dependency Inversion Principle (DIP) in C

Dependency Inversion Principle (DIP) in C

Dependency Inversion Principle featured image with purple background, SOLID badge, D icon, DIP in C subtitle in the Software Design in C series by NerdyElectronics
Software Design Principles in C
Part 6 of 31View Full Path →

KEY TAKEAWAYS

  • DIP: depend on abstractions (function pointers, interface structs), not on concrete implementations
  • Pass dependencies in (via init functions or constructor parameters) rather than hardcoding them
  • This makes your code testable (inject fakes), portable (swap hardware drivers), and flexible
  • In C, the abstraction is typically a function pointer or a struct of function pointers defined in a header file

What Is the Dependency Inversion Principle?

The Dependency Inversion Principle (DIP) states: high-level modules should not depend on low-level modules. Both should depend on abstractions.In plain terms: your business logic should not directly call uart_send() or flash_write(). Instead, it should call through a function pointer or an interface struct that can be swapped out — for testing, for a different platform, or for a different driver.

Example 1: Business Logic Directly Calling UART

Bad: High-level module depends on low-level module

/* alarm.c - directly calls uart functions */
#include "uart.h"  /* Hard dependency on UART */

void alarm_check(int temperature) {
    if (temperature > 80) {
        uart_send_string("ALARM: Temperature too high!rn");
        uart_send_int(temperature);
    }
}
Problems:
  • Cannot test alarm_check on a PC without a real UART
  • Cannot switch to Bluetooth, SPI display, or cloud logging without editing alarm.c
  • alarm.c is tightly coupled to uart.c

Good: Inject the dependency through a function pointer

/* output.h - the abstraction */
typedef void (*output_fn)(const char *message);

/* alarm.c - depends on the abstraction, not on UART */
#include "output.h"

static output_fn notify = NULL;

void alarm_init(output_fn output_handler) {
    notify = output_handler;
}

void alarm_check(int temperature) {
    if (temperature > 80 && notify != NULL) {
        char msg[64];
        sprintf(msg, "ALARM: Temperature %d C - too high!", temperature);
        notify(msg);
    }
}

/* main.c - wires up the dependency */
#include "uart.h"
#include "alarm.h"

void uart_output(const char *msg) {
    uart_send_string(msg);
    uart_send_string("rn");
}

int main(void) {
    alarm_init(uart_output);  /* Inject UART */
    /* ... */
}

/* test_alarm.c - inject a fake for testing */
static char last_message[64];
void fake_output(const char *msg) {
    strcpy(last_message, msg);
}

void test_alarm_triggers(void) {
    alarm_init(fake_output);
    alarm_check(85);
    assert(strstr(last_message, "ALARM") != NULL);  /* Pass! */
}
Now alarm.c has zero knowledge of UART. You can inject any output method: UART, Bluetooth, a test stub, or even a file logger.

Example 2: Logging Through an Abstract Interface

Bad: Logger hardcoded to write to serial

/* logger.c */
#include "uart.h"

void log_info(const char *msg) {
    uart_send_string("[INFO] ");
    uart_send_string(msg);
    uart_send_string("rn");
}

void log_error(const char *msg) {
    uart_send_string("[ERROR] ");
    uart_send_string(msg);
    uart_send_string("rn");
}

Good: Logger depends on an abstract output interface

/* logger.h - defines the logger interface */
typedef struct {
    void (*write)(const char *message);
} log_output_t;

void logger_init(const log_output_t *output);
void log_info(const char *msg);
void log_error(const char *msg);

/* logger.c - uses the abstraction */
static const log_output_t *out = NULL;

void logger_init(const log_output_t *output) {
    out = output;
}

void log_info(const char *msg) {
    if (out && out->write) {
        char buf[128];
        snprintf(buf, sizeof(buf), "[INFO] %s", msg);
        out->write(buf);
    }
}

void log_error(const char *msg) {
    if (out && out->write) {
        char buf[128];
        snprintf(buf, sizeof(buf), "[ERROR] %s", msg);
        out->write(buf);
    }
}

/* uart_output.c - one concrete implementation */
void uart_write(const char *message) {
    uart_send_string(message);
    uart_send_string("rn");
}
log_output_t uart_logger = { .write = uart_write };

/* sd_card_output.c - another implementation */
void sd_write(const char *message) {
    sd_card_append_line(message);
}
log_output_t sd_logger = { .write = sd_write };

/* main.c - choose at startup */
logger_init(&uart_logger);   /* Or &sd_logger */
log_info("System started");
log_error("Sensor timeout");

The Dependency Direction

BAD (direct dependency):
  alarm.c ──depends on──▶ uart.c

GOOD (inverted dependency):
  alarm.c ──depends on──▶ output.h (abstraction)
  uart.c  ──implements──▶ output.h (abstraction)
Both the high-level module (alarm) and the low-level module (UART) depend on the abstraction (output.h). Neither depends on the other directly.

Key Takeaways

  • DIP: depend on abstractions (function pointers, interface structs), not on concrete implementations
  • Pass dependencies in (via init functions or constructor parameters) rather than hardcoding them
  • This makes your code testable (inject fakes), portable (swap hardware drivers), and flexible
  • In C, the abstraction is typically a function pointer or a struct of function pointers defined in a header file

Example 3: Sensor Abstraction for Testing

One of the biggest benefits of DIP is testability. When your code depends directly on hardware, testing requires real hardware. With an abstraction layer, you can swap in a fake.

Bad — Direct Hardware Dependency

#include "i2c_driver.h"
#include "bmp280_registers.h"

float read_temperature(void) {
    uint8_t raw[3];
    i2c_read(BMP280_ADDR, BMP280_TEMP_REG, raw, 3);
    int32_t adc_T = (raw[0] << 12) | (raw[1] <> 4);
    // BMP280 compensation formula
    float temp = compensate_temperature(adc_T);
    return temp;
}

void check_overtemp(void) {
    float t = read_temperature();  // Can't test without real BMP280
    if (t > 85.0f) {
        activate_cooling();
    }
}
You cannot unit test check_overtemp on your development machine — it calls real I2C hardware.

Good — Inject the Sensor Interface

// sensor.h — the abstraction
typedef struct {
    float (*read_temp)(void *ctx);
    void *ctx;
} TempSensor;

// business logic depends only on the abstraction
void check_overtemp(const TempSensor *sensor) {
    float t = sensor->read_temp(sensor->ctx);
    if (t > 85.0f) {
        activate_cooling();
    }
}

// --- Production: real BMP280 ---
float bmp280_read(void *ctx) {
    // real I2C read + compensation
    return compensate_temperature(i2c_read_temp(ctx));
}
TempSensor hw_sensor = { .read_temp = bmp280_read, .ctx = &i2c_handle };

// --- Test: fake sensor ---
float fake_read(void *ctx) {
    return *(float *)ctx;  // return whatever value we set
}

void test_overtemp(void) {
    float fake_temp = 90.0f;
    TempSensor fake = { .read_temp = fake_read, .ctx = &fake_temp };
    check_overtemp(&fake);  // Now testable without hardware!
}
The business logic never touches I2C directly. In production, you pass the real sensor. In tests, you pass a fake that returns any temperature you want. This is the power of DIP — your high-level logic is completely decoupled from low-level hardware details.

DIP vs Dependency Injection

These terms are related but different:
  • Dependency Inversion Principle (DIP) is the design rule: high-level modules should not depend on low-level modules. Both should depend on abstractions.
  • Dependency Injection (DI) is a technique to implement DIP: you pass (inject) dependencies from outside rather than creating them internally.
In C, dependency injection typically means passing function pointers or interface structs as parameters instead of calling concrete functions directly. You do not need a DI framework — function pointers and structs are all you need.

📖 Related: Hardware Abstraction Layer (HAL) Design in C — With Examples

Leave a Reply

Your email address will not be published. Required fields are marked *