DEV Community

Cover image for The Hybrid Power of OOP and FP: Building Scalable Architectures in Java 21+ and C# .NET 9
M Lukman
M Lukman

Posted on

1

The Hybrid Power of OOP and FP: Building Scalable Architectures in Java 21+ and C# .NET 9

Table of Contents

Introduction

In modern software architecture, the convergence of paradigms has become a strategic imperative. Enterprises are no longer debating between Object-Oriented Programming (OOP) and Functional Programming (FP); instead, they are embracing the best of both. This hybrid approach empowers teams to create robust, scalable, and expressive systems where OOP forms the architectural spine and FP governs behavioral logic and flow.

This article dives deep into implementing a paradigm-pure hybrid architecture using Java 21+ and C# .NET 9.0, showcasing real-world scenarios such as validation policies, error propagation, repository abstraction, result pipelines, and CQRS. We will also eliminate all imperative branching (no if, switch, try-catch) in favor of delegation, pattern matching, polymorphism, and higher-order function composition.


1. Core Philosophy

The hybrid model is governed by these tenets:

  • OOP is structural — model capabilities, domain contracts, and extensibility through interfaces and polymorphism.
  • FP is behavioral — use pure functions, monadic flows, and pattern matching for transformations, control flow, and predictable side-effect isolation.
  • No branching allowed — all decisions are made via strategy injection, interface dispatch, or ADT pattern matching.

2. Result Type as an Algebraic Data Type

Functional error handling is better modeled using ADTs than exceptions. We define a Result<T> type representing Success<T> and Failure<T>.

2.1 Java 21+: Sealed Records + Pattern Matching + Lambdas

public sealed interface Result<T> permits Success, Failure {
    void handle(ResultHandler<T> handler);

    default <R> R match(Function<T, R> onSuccess, Function<String, R> onFailure) {
        return switch (this) {
            case Success<T> s -> onSuccess.apply(s.value());
            case Failure<T> f -> onFailure.apply(f.reason());
        };
    }

    default <R> Result<R> map(Function<T, R> mapper) {
        return match(val -> new Success<>(mapper.apply(val)), Failure::new);
    }
}

public record Success<T>(T value) implements Result<T> {
    public void handle(ResultHandler<T> handler) { handler.onSuccess(value); }
}

public record Failure<T>(String reason) implements Result<T> {
    public void handle(ResultHandler<T> handler) { handler.onFailure(reason); }
}

public interface ResultHandler<T> {
    void onSuccess(T value);
    void onFailure(String reason);
}
Enter fullscreen mode Exit fullscreen mode

2.2 C# .NET 9.0: Interfaces + Primary Constructors + Lambda Delegates

public interface IResult<T>
{
    void Handle(IResultHandler<T> handler);
    TResult Match<TResult>(Func<T, TResult> onSuccess, Func<string, TResult> onFailure);
    IResult<TResult> Map<TResult>(Func<T, TResult> map);
}

public sealed record Success<T>(T Value) : IResult<T>
{
    public void Handle(IResultHandler<T> handler) => handler.OnSuccess(Value);
    public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<string, TResult> onFailure) => onSuccess(Value);
    public IResult<TResult> Map<TResult>(Func<T, TResult> map) => new Success<TResult>(map(Value));
}

public sealed record Failure<T>(string Reason) : IResult<T>
{
    public void Handle(IResultHandler<T> handler) => handler.OnFailure(Reason);
    public TResult Match<TResult>(Func<T, TResult> onSuccess, Func<string, TResult> onFailure) => onFailure(Reason);
    public IResult<TResult> Map<TResult>(Func<T, TResult> map) => new Failure<TResult>(Reason);
}

public interface IResultHandler<T>
{
    void OnSuccess(T value);
    void OnFailure(string reason);
}
Enter fullscreen mode Exit fullscreen mode

3. Strategy Pattern for Validation

3.1 Java 21+ Functional Strategy

public interface ValidationPolicy {
    Result<String> validate(String input);
}

public class AllowAllPolicy implements ValidationPolicy {
    public Result<String> validate(String input) {
        return new Success<>(input);
    }
}

public class EmailPolicy implements ValidationPolicy {
    public Result<String> validate(String input) {
        return input.contains("@") ? new Success<>(input) : new Failure<>("Invalid email");
    }
}
Enter fullscreen mode Exit fullscreen mode

3.2 C# .NET 9.0 Functional Strategy

public interface IValidationPolicy
{
    IResult<string> Validate(string input);
}

public sealed class AllowAllPolicy : IValidationPolicy
{
    public IResult<string> Validate(string input) => new Success<string>(input);
}

public sealed class EmailPolicy : IValidationPolicy
{
    public IResult<string> Validate(string input) =>
        input.Contains("@") ? new Success<string>(input) : new Failure<string>("Invalid email");
}
Enter fullscreen mode Exit fullscreen mode

4. Composing Result Pipelines

4.1 Java Chained Example

public static Result<String> getUser(String id) {
    return id.length() > 0 ? new Success<>("User-" + id) : new Failure<>("Empty ID");
}

public static Result<Integer> getAge(String username) {
    return new Success<>(username.length() + 20);
}

public static String pipeline(String id) {
    return getUser(id)
        .map(name -> name.toUpperCase())
        .map(name -> name + "_Validated")
        .match(
            val -> "Success: " + val,
            err -> "Error: " + err
        );
}
Enter fullscreen mode Exit fullscreen mode

4.2 C# .NET 9.0 Pipeline

public static IResult<string> GetUser(string id) =>
    id.Length > 0 ? new Success<string>("User-" + id) : new Failure<string>("Empty ID");

public static IResult<int> GetAge(string username) =>
    new Success<int>(username.Length + 20);

public static string Pipeline(string id) =>
    GetUser(id)
        .Map(name => name.ToUpper())
        .Map(name => name + "_VALIDATED")
        .Match(
            success => $"Success: {success}",
            error => $"Error: {error}"
        );
Enter fullscreen mode Exit fullscreen mode

5. Domain-Driven Repository with Strategy Injection

5.1 Java Repository Interface with Injection

public interface UserRepository {
    Result<User> findById(String id);
}

public class InMemoryUserRepository implements UserRepository {
    private final Map<String, User> users = Map.of("1", new User("1", "Alice"));

    public Result<User> findById(String id) {
        return Optional.ofNullable(users.get(id))
            .<Result<User>>map(Success::new)
            .orElse(new Failure<>("User not found"));
    }
}
Enter fullscreen mode Exit fullscreen mode

5.2 C# Repository Interface

public interface IUserRepository
{
    IResult<User> FindById(string id);
}

public sealed class InMemoryUserRepository : IUserRepository
{
    private readonly Dictionary<string, User> _users = new() { { "1", new User("1", "Alice") } };

    public IResult<User> FindById(string id) =>
        _users.TryGetValue(id, out var user) ? new Success<User>(user) : new Failure<User>("User not found");
}
Enter fullscreen mode Exit fullscreen mode

6. CQRS and Use Case Orchestration

6.1 Java Command Handler

public record CreateUserCommand(String id, String name) {}

public interface CommandHandler<TCommand, TResult> {
    Result<TResult> handle(TCommand command);
}

public class CreateUserHandler implements CommandHandler<CreateUserCommand, User> {
    private final UserRepository repo;

    public CreateUserHandler(UserRepository repo) {
        this.repo = repo;
    }

    public Result<User> handle(CreateUserCommand command) {
        return new Success<>(new User(command.id(), command.name()));
    }
}
Enter fullscreen mode Exit fullscreen mode

6.2 C# Command Handler

public record CreateUserCommand(string Id, string Name);

public interface ICommandHandler<TCommand, TResult>
{
    IResult<TResult> Handle(TCommand command);
}

public sealed class CreateUserHandler : ICommandHandler<CreateUserCommand, User>
{
    public IResult<User> Handle(CreateUserCommand command) =>
        new Success<User>(new User(command.Id, command.Name));
}
Enter fullscreen mode Exit fullscreen mode

7. Benefits and Trade-offs

✅ Benefits of the Hybrid Approach

1. Modularity and Extensibility

Combining OOP structure with FP behavior enables separation of concerns and decouples control flow from data structures. Strategy patterns and sealed interfaces allow logic injection without editing existing code (Open/Closed Principle).

2. Testability

Each component (policies, handlers, results) is isolated and mockable. FP flows are predictable and deterministic, enabling easier unit and property-based testing.

3. Expressiveness and Clarity

FP-style chaining (map, match, etc.) leads to more declarative and intention-revealing code. No hidden state or control side effects improves readability.

4. Runtime Composability

You can dynamically compose logic without writing glue code. Polymorphic handlers and lambdas form composable behavioral trees.

5. Fault Tolerance

No exceptions are thrown. All errors are expressed and handled explicitly. This improves reliability in distributed systems and services.


⚠️ Limitations and Trade-offs

1. Learning Curve

Understanding how to combine OOP’s polymorphism and FP’s monadic flow may be complex for newcomers, especially when aiming for 100% branchless logic.

2. Over-Engineering Risk

Using ADTs and handler interfaces everywhere can become verbose and excessive for small-scale systems or simple CRUD applications.

3. Boilerplate (Until Language Maturity Catches Up)

While Java 21 and C# 9+ alleviate a lot, defining handler records, sealed interfaces, and mapper chains still incurs some boilerplate compared to dynamically typed languages like Kotlin or Scala.

4. Indirect Flow

Tracing through polymorphic delegation + map chains may be less direct than stepping through if branches. This requires good documentation and code organization.


8. When to Use Hybrid OOP+FP

Scenario Suitability
Domain-Driven Design ✅ Ideal — clear boundary, rich domain, strategy injection
Event Sourcing / CQRS ✅ Excellent — handlers, result mapping, immutable data
ETL Pipelines / Functional Flows ✅ Expressive — declarative transformation
Simple MVC Web App ❌ Overkill — too much indirection
Systems with External Side Effects ✅ Good — isolate effects via polymorphic handlers

9. Final Thoughts

The hybrid OOP + FP paradigm enables powerful, scalable, and maintainable software systems. By modeling structure through interfaces and records, and expressing behavior through composition and pattern matching, developers gain clarity, correctness, and flexibility.

Key Takeaways:

  • Use sealed types + pattern matching (Java 21) or record types + match delegates (C# 9) to eliminate branching.
  • Apply polymorphic delegation to abstract all control flow.
  • Model results and effects as types to create predictable and composable systems.
  • Adopt a declarative mindset, even in traditionally imperative environments.

When OOP gives your system its shape, FP gives it the rhythm. The result is an architecture that sings.


📬 Connect with Me

If you found this article insightful and you're looking for someone with deep experience in hybrid OOP/FP architecture, clean systems design, or enterprise-grade backend engineering — I’m open to professional opportunities and collaborations.

Let’s build resilient, declarative, and elegant software — together.

ACI image

ACI.dev: Best Open-Source Composio Alternative (AI Agent Tooling)

100% open-source tool-use platform (backend, dev portal, integration library, SDK/MCP) that connects your AI agents to 600+ tools with multi-tenant auth, granular permissions, and access through direct function calling or a unified MCP server.

Star our GitHub!

Top comments (0)

Tiger Data image

🐯 🚀 Timescale is now TigerData: Building the Modern PostgreSQL for the Analytical and Agentic Era

We’ve quietly evolved from a time-series database into the modern PostgreSQL for today’s and tomorrow’s computing, built for performance, scale, and the agentic future.

So we’re changing our name: from Timescale to TigerData. Not to change who we are, but to reflect who we’ve become. TigerData is bold, fast, and built to power the next era of software.

Read more

👋 Kindness is contagious

Explore this insightful write-up embraced by the inclusive DEV Community. Tech enthusiasts of all skill levels can contribute insights and expand our shared knowledge.

Spreading a simple "thank you" uplifts creators—let them know your thoughts in the discussion below!

At DEV, collaborative learning fuels growth and forges stronger connections. If this piece resonated with you, a brief note of thanks goes a long way.

Okay