Tuesday, November 25, 2025

Abstraction in Java: Focusing on What Matters

# Abstraction in Java: Focusing on What Matters

Abstraction in Java: Focusing on What Matters

Abstraction is one of the pillars of the Object Oriented Programming paradigm and it is probably one of the most misunderstood concepts. I’ve seen colleagues and myself, let’s be honest, confuse it with encapsulation a lot of times. Many people think it’s just about creating interfaces and abstract classes. But abstraction is something more fundamental, and honestly, understanding it well helps you go from good code to great code.

What Actually is Abstraction?

Abstraction is the art of hiding complexity and exposing only what’s necessary to achieve the functionality. When you drive your car, you don’t need to be a mechanic or know about how the internal combustion engine works. You just grab your keys, start the engine and from there you push the gas pedal to advance, use the brake pedal to stop, and turn the steering wheel to the direction you want to go. That’s abstraction my friend.

When using abstraction you show only the essential features of an object and hiding the unnecessary details.

The goal is to reduce complexity by breaking down a system into smaller, manageable pieces where each piece has a clear, simple, intention revealing interface. When done correctly, and this is not simple, of course, abstraction lets you think about problems at a higher level without getting bogged down in implementation details.

Abstraction vs Encapsulation - Ok yeah, so what’s the difference then?

Great question!

Encapsulation is about controlling access to data to offer protection and avoid inconsistencies. It’s about bundling data with the methods that operate on that data, and restricting direct access to some of the internal components.

Abstraction is about hiding complexity and showing only relevant details. It’s a simplified view of something complex. Think of it as creating a simple universal remote control for a complicated entertainment system.

Both concepts are hiding (or protecting) something: Encapsulation is hiding information and Abstraction is hiding implementation details.

Let us see some code to illustrate the encapsulation concept (the following example is shorter version of the code from another post in my blog that talks about encapsulation: https://abaddon-gtz.blogspot.com/2025/08/encapsulation-in-java-writing-secure.html)

public class BadBankAccount {
    // Private fields - hidden from outside access
    private String owner;
    private double balance;
    private List<String> transactions;
    private static final double MINIMUM_BALANCE = 0.0;

    // Constructor with validation (This can be better implemented as a factory method)
    public BadBankAccount(String owner, double initialBalance) {
        if (owner == null || owner.trim().isEmpty()) {
            throw new IllegalArgumentException("Owner name cannot be null or empty");
        }
        if (initialBalance < MINIMUM_BALANCE) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }

        this.owner = owner;
        this.balance = initialBalance;
        this.transactions = new ArrayList<>();
        addTransaction("Account opened with balance: $" + initialBalance);
    }

    // Business logic for deposits
    public boolean deposit(double amount) {
        if (amount <= 0) {
            System.out.println("Deposit amount must be positive");
            return false;
        }

        balance += amount;
        addTransaction("Deposited: $" + amount + " | New balance: $" + balance);
        return true;
    }

    // more code
}

The focus here is protection. We’re preventing invalid states and controlling access to the internals to avoid inconsistencies.

Now look at abstraction:

// A checkout service that process payments
public class CheckoutService {
    private PaymentProcessor processor;

    public void checkout(double amount) {
        // simple interface, abstracting complexity
        boolean success = processor.processPayment(amount);
        if (success) {
            completeOrder();
        }
    }
}

// ABSTRACTION!
public interface PaymentProcessor {
    boolean processPayment(double amount);
}

// Implementation 1 - Credit Card (complex internal logic)
public class CreditCardProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        // Hidden complexity:
        // - Validate card number
        // - Check CVV
        // - Contact payment service
        // - Handle security
        // - Deal with declined transactions or other errors
        // - Manage retry logic
        // All this complexity is hidden behind a simple interface
        return true;
    }
}

// Implementation 2 - PayPal (different complex logic)
public class PayPalProcessor implements PaymentProcessor {
    @Override
    public boolean processPayment(double amount) {
        // Hidden complexity:
        // - authentication
        // - PayPal API calls
        // - Handle different currencies
        // - etc, etc, etc
        // Again, complexity hidden
        return true;
    }
}

The focus here is simplification. We’re hiding different complex implementations behind a common, simple interface.

They Work Together

More often than not these concepts are used together and probably because of that, I think, it is why we tend to confuse them a lot.

Again, using the example of driving a car, there is encapsulation and abstraction in action. You don’t have to open the hood of the car to start the engine manually and also to turn the car to the direction you want to go you use the simple interface that is the steering wheel.

Simple quick mental model to remember the concepts

Ask yourself:

  • “Am I protecting data and controlling access?” → That’s encapsulation
  • “Am I hiding complexity and providing a simpler interface?” → That’s abstraction

Both make your code better, but for different reasons. Encapsulation keeps your data safe and consistent. Abstraction keeps your code manageable and flexible.

Do I really need them?

Without proper abstraction and encapsulation, your code can become a tangled mess where:

  • You need to understand too much to do simple things
  • Changes in one place break things in unexpected places (we’ve all know this pain!)
  • New team members take forever to understand what’s going on in the code
  • Testing becomes a nightmare because everything is coupled to everything

So, yeah! you need them!

Imagine you are building a simple tool to migrate data from one database to another and because some business and safety requirements, there are some steps that you need to enforce like:

  • you must create a backup before copying the data
  • after running the migration you need to validate data is actually copied in the database.

Now imagine you write a class like the following:

/**
 * <Some very large and detailed documentation of how to use this tool>
 */
public class DatabaseMigrationTool {
    // lot of attributes

    public void setSourceDatabase(String host, String db, String user, String password) { ... }
    public void setTargetDatabase(String host, String db, String user, String password) { ... }

    public void createBackup() {
        // Implementation details omitted for brevity
        this.backupCreated = true;

        // nullpointer exceptions if there is no source and target data defined :D
    }

    public void migrateData() {
        // you cannot transfer the data if the backup wasn't created
        if (!backupCreated) throw new IllegalStateException("Hey, did you forget to backup?");

        // Implementation details omitted for brevity
        this.dataTransferred = true;
    }

    public void validateMigratedData() {
        // you cannot validate copied data if the data wasn't transfered first!
        if (!dataTransferred) throw new IllegalStateException("Come on! really?!");
    }
}

This is probably not that bad. If a developer that does not like to read documentation tries to use it, he probably will hit his head against the wall a couple of times before learning there is some order of how to call the methods.

DatabaseMigration migration = new DatabaseMigration();
migration.migrateData(); // ❌ Oops! No backup!
migration.setSourceDatabase(...);
migration.validateMigratedData(); // ❌ Oops!
migration.setTargetDatabase(...);

With Abstraction (clean and intention revealing)

Let us use the beautiful “Step Builder Pattern” that combines Fluent interfaces and the Builder pattern.

public interface FirstStep {
    SecondStep fromDatabase(String host, String db, String user, String password);
}

public interface SecondStep {
    ThirdStep toDatabase(String host, String db, String user, String password);
}

public interface ThirdStep {
    FourthStep withBackup();
}

public interface FourthStep {
    FifthStep migrateData();
}

public interface FifthStep {
    FinalStep validateMigration();
}

public interface FinalStep {
    void finish();
}

public class DatabaseMigrationTool {
    // you have the same attributes and methods defined before but they are private
    // no one can call them if not using the builder

    // The builder!
    public static FirstStep builder() {
        return new Builder();
    }

    private static class Builder
        implements FirstStep, SecondStep, ThirdStep, FourthStep, FifthStep, FinalStep {
        private DatabaseMigrationTool tool = new DatabaseMigrationTool();

        @Override
        public SecondStep fromDatabase(String host, String db, String user, String password) {
            // Add validation here
            this.tool.setSourceDatabase(host, db, user, password);
            return this;
        }

        @Override
        public ThirdStep toDatabase(String host, String db, String user, String password) {
            // Add validation here
            this.tool.setTargetDatabase(host, db, user, password);
            return this;
        }

        @Override
        public FourthStep withBackup() {
            this.tool.createBackup();
            return this;
        }

        @Override
        public FifthStep migrateData() {
            this.tool.migrateData();
            return this;
        }

        @Override
        public FinalStep validateMigration() {
            this.tool.validateMigratedData();
            return this;
        }

        @Override
        public void finish() {
            System.out.println("Done!");
        }
    }
}

Now if some developer wants to use it, the different interfaces that define the flow of the steps enforce the correct usage:

DatabaseMigrationTool.builder()
    .fromDatabase(...)
    .toDatabase(...)
    .withBackup()
    .migrateData()
    .validateMigration()
    .finish();

You could probably say: “I can simply define a ‘execute’ method calling all methods in the correct order”. Yes, of course, but what if later you need to add other steps that can be skipped according to the needs of each individual user of the tool?

public interface SkippableStep {
    NextStep executeStep();
    NextStep skipStep();
}

That’s the power of abstraction.

Achieving Abstraction with Java

1. Interfaces - The Foundation

Interfaces define contracts without implementation details.

public interface PaymentProcessor {
    PaymentResult processPayment(double amount, PaymentMethod method);
    boolean refund(String transactionId);
    TransactionStatus checkStatus(String transactionId);
}

// Different implementations
public class StripePaymentProcessor implements PaymentProcessor { /* ... */ }
public class PayPalPaymentProcessor implements PaymentProcessor { /* ... */ }

2. Abstract Classes - use them when you need some common stuff

Use abstract classes when you have shared behavior but still need abstraction:

public abstract class ReportGenerator {
    // Template method design pattern - defines the overall algorithm structure
    public final Report generate(String data) {
        Report report = createReport();
        report.setHeader(generateHeader());
        report.setBody(processData(data));
        report.setFooter(generateFooter());
        return report;
    }

    // shared behavior
    protected String generateHeader() {
        return "Report Generated: " + LocalDateTime.now();
    }

    // Abstract methods - let subclasses decide how to implement the functionality
    protected abstract Report createReport();
    protected abstract String processData(String data);
    protected abstract String generateFooter();
}

3. Depend on Abstractions, Not Concrete Implementations

Your high-level code should depend on interfaces, not concrete classes. SOLID Dependency Inversion principle!

// Bad
public class OrderService {
    private MySQLOrderRepository repository; // Tied to MySQL!

    public OrderService() {
        this.repository = new MySQLOrderRepository();
    }
}

// Good - depends on abstraction
public class OrderService {
    private final OrderRepository repository; // Any implementation works!

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

Some good practices I’ve learned

  • Start with the interface, not the implementation. When designing a new component, ask yourself: “What operations do I need?” not “How will I implement this?” Design the contract first.
  • Keep abstractions focused. An interface should represent one concept. If your interface is doing too much, split it up. SOLID Interface Segregation Principle is your friend here.
  • Don’t create abstractions right away.* Wait until you have at least two implementations or a clear reason. Over-abstraction is as bad as under-abstraction. I’ve made this mistake more times than I’d like to admit.
  • Name abstractions by what they do, not how they do it. Use NotificationChannel instead of EmailSender. The abstraction shouldn’t leak implementation details in its name.
  • Test through abstractions. Write tests against interfaces, not concrete classes. This makes your tests more resilient to implementation changes and you can use mocks this way.

Conclusion

Abstraction is about managing complexity by hiding unnecessary details and exposing clean, intuitive interfaces. When you get it right, your code becomes easier to understand, easier to test, and way easier to change. Getting right abstractions does not happen at the first try, it requires practice, design patterns knowledge, and experimentation.

Good abstractions reduce the need for extensive documentation (caution, I’m not saying you don’t need documentation) because the code itself clearly expresses intent. The abstractions become the language you use to think about and discuss your system.

Do you have other examples of abstraction to share?

Friday, October 10, 2025

OpenAI Travel Agent example with Embabel

# Intro

Intro

Recently OpenAI presented its new product: an Agent Builder. They revealed that in the keynote of the OpenAI DevDay 2025 that is happening in San Francisco.

To show the capabilities of the Agent Builder they created a workflow that uses 2 agents to provide information about the sessions of the DevDay. They used files to provide context to one of the agents to be able to know the sessions occurring in the DevDay and also a Widget to show information directly in the ChatGPT user interface.

Later the same day OpenAI released a youtube video showing another demo of the Agent Builder “Intro to Agent Builder” (https://www.youtube.com/watch?v=44eFf-tRiSg). This time the video demo a AI workflow for a Travel Agent that classifies the user input to determine if the user wants an itinerary suggestion or if they are looking for a flight to travel.

If you are a Java or Kotlin developer you can do that right now with Embabel.

Building the TravelAgent

Find the example project here abadongutierrez/openai-agent-demo-with-embabel

Agent class

To create an agent with Embabel you need to annotate your class with annotation @Agent. In the example code there is class OpenAITravelAgent:

@Agent(
    description = "Help the user to generate a Itinerary or find a flight"
)
public class OpenAITravelAgent { ... }

This will tell the Embabel engine that this class contains an agent. When running the application within Embabel there is a mechanism that selects which agent can attend the user based on its description, an LLM is used here to select the agent.

Agent Actions

An action is where the agents do their work. Actions are methods annotated with @Action within the agent class.

Everything within Embabel is strongly typed so actions can receive and return your classes. This is amazing because we can easily refactor when needed.

The types the actions receives as arguments indicates its preconditions and the type the action returns indicate its postconditions. This is very important because in order to determine which action to execute Embabel uses a planning step after the execution of each action (this is done with a non-LLM AI algorithm that probably those that know something about game development are familiar with). This planning feature enable Embabel agents to perform tasks that go beyond sequential execution because the next step is determined by combining the known steps and the state of the agent world (values, conditions, preconditions that the agent tracks).

Classify user input

The first action to execute is that method where their only condition is to receive the UserInput to start the flow. That method is classify in the OpenAITravelAgent class:

@Action(description = "Classify the user input")
Option classify(UserInput userInput, OperationContext context) {
    Option option = context.ai()
        .withAutoLlm()
        .createObject(String.format("""
            You are a helpful travel assistant for classifying whether a message is about an itinerary or a flight

            # User input
            %s
            """,
            userInput.getContent()
        ).trim(), Option.class);

    return option;
}

This method also receives the OperationContext of the agent flow that is executed. This class gives us the ability to execute AI operations with LLMs (among other things). In this case, we are asking an LLM (the default LLM) to analyze the user input and classify if the user wants an suggestion for an itinerary or a flight.

But we aren’t asking for a simple string as a result, we want the result to be a enumeration of type Option which could be ITINERARY or FLIGHT.

Deciding the kind of suggestion

Once the agent knows what the users needs the next action to execute is decide and this is because the precondition of the action is to have the UserInput and the Option.

@Action
ItineraryOrFlightRequest decide(UserInput userInput, OperationContext context, Option option) {
    if (option == Option.ITINERARY) {
        return new ItineraryOrFlightRequest(new ItineraryRequest(userInput), null);
    } else {
        return new ItineraryOrFlightRequest(null, new FlightRequest(userInput));
    }
}

This action basically decides which kind of request the agent is going to attend a ItineraryRequest or a FlightRequest and to do that the request type is wrapped in a class ItineraryOrFlightRequest which is a class that implements interface SomeOf.

Types implementing the interface SomeOf basically tells the Embabel that, when planning the next step, some sort of “switch” (or if-else) needs to be executed to decide the next action in the flow.

Creating the Itinerary or looking for a Flight

Whether the user is requesting an itinerary or looking for a flight actions itineraryAction or flightAction are executed:

@Action(description = "Build a travel itinerary based on user input")
Itinerary itineraryAction(ItineraryRequest request, OperationContext context) {
    Itinerary itinerary = context.ai()
        .withAutoLlm()
        .createObject(String.format("""
            You are a travel assistant, so build a concise itinerary based on the user input

            Today is: %s
            User input: %s
            """,
            LocalDateTime.now(),
            request.userInput().getContent()
        ).trim(), Itinerary.class);
    return itinerary;
}

@Action(description = "Find a flight based on user input")
Flight flightAction(FlightRequest request, OperationContext context) {
    Flight flight = context.ai()
        .withAutoLlm()
        .createObject(String.format("""
            You are a travel assistant. Always recommend a specific flight to go to. Use airport codes.

            Today is: %s
            User input: %s
            """,
            LocalDateTime.now(),
            request.userInput().getContent()
        ).trim(), Flight.class);
    return flight;
}

Both actions receive the request type (ItineraryRequest or FlightRequest) and, using a LLM create an Itinerary or a Flight to give that as a recommendation to the user.

In this code example I am using a API key from OpenAI so when asking something to their LLM if the LLM determine it needs to search the web to get extra info it will do it, in this case the LLM is doing that to get me a real flight with its number, the departure and destination as airport codes and the date.

But if I were using some local LLM there are ways to indicate the local LLM to use some internal tools to help it find the information requested, for example:

context.ai()
  .withDefaultLlm()
  .withToolGroup(CoreToolGroups.WEB) // Use the Web as tool!
  .createObject(...)

Fulfilling the goal

So once we have an Itinerary of a Flight the next step is basically tell the user the final recommendation. That happens in actions provideTravelRecommendationAsItinerary or provideTravelRecommendationAsFlight that basically create a text description to show to the user.

@AchievesGoal(
    description = "The user has received a travel itinerary"
)
@Action
Recomendation provideTravelRecommendationAsItinerary(Itinerary recommendable) {
    return new Recomendation(recommendable.recommendation());
}

@AchievesGoal(
    description = "The user has received a flight recommendation"
)
@Action
Recomendation provideTravelRecommendationAsFlight(Flight recommendable) {
    return new Recomendation(recommandable.recommendation());
}

It is important to notice here that these final actions are annotated with @AchievesGoal which basically indicates that these are the final steps for the agent and that executing them achieves the goal indicated in the description.

Defining at least one goal for the agent is important because otherwise the agent does know when to stop.

Running the agent

Before running the application you need to have an api key from OpenAI and setup the environmental variable OPENAI_API_KEY in your terminal.

export OPENAI_API_KEY=XXXXXXXXXXXX

Note: If you want to try modifying the code to run this little app using local LLM with Ollama, you will need some modifications like I have in the repo I used for the following post: https://abaddon-gtz.blogspot.com/2025/07/a-simple-agent-to-summarize-web-content.html

To run the application you can execute the “shell” script in “scripts” folder directly in the root of the project:

> ./scripts/shell.sh

This will start the Spring Shell application and it will give you a prompt where you can run commands.

To run the agent and get an itinerary suggestion you could execute in the spring shell the following: execute "what should I do in a day in tokyo?". This command will give you and output similar to:

You asked: UserInput(content=what should I do in a day in tokyo?, timestamp=2025-10-10T16:02:38.157335Z)

{
  "text" : "Start your day with a visit to the Meiji Shrine to experience traditional Japanese culture. Then, head to Harajuku for vibrant street fashion and lunch. In the afternoon, explore Shibuya, see the famous crossing, and visit Hachiko statue. Next, take the train to Asakusa to see Senso-ji Temple and shop along Nakamise Street. Finish your day in the evening at Tokyo Skytree for panoramic city views and dinner at Solamachi shopping complex."
}

but what if you want is a flight you can ask: execute "I want to flight to paris next week" and get something like:

You asked: UserInput(content=I want to flight to paris next week, timestamp=2025-10-10T16:03:11.831736Z)

{
  "text" : "Flight Number: AF007\nDeparture: JFK\nArrival: CDG\nDate: 2025-10-17T14:00"
}

Conclusion

Embabel provides a great alternative for building agents by embracing the strengths of strongly-typed languages like Java and Kotlin. It is a framework still under development but it already shows great potential.

So if you want to start experimenting with how you can integrate AI tools in your applications you can do that with Embabel and AI Agents now.

By the way, Embabel is built on the Spring Framework and Spring AI so everything you can use in Spring you can use within Embabel!

Find the documentation here: https://docs.embabel.com/embabel-agent/guide/0.1.3/