Course contents

Object relationships: interview questions & scenarios

Free13 questions · 26 what-if follow-ups

These are the object-relationship questions I ask, and get asked, in core Java and low-level design rounds. They look like vocabulary, but every one is really about lifecycle, coupling and what happens when the code changes next year.

Here's how to use this page: read the question, say your answer out loud first, then open the model answer. The "What if..." follow-ups are the ones interviewers actually ask next. If a question stumps you, the lessons are in this topic: Inheritance vs composition and Association, aggregation and composition.

Question 1. ConceptWarm-upAsked very often

What's the difference between is-a and has-a, and which do you prefer?

Tests inheritance, composition, design defaults

Show answerHide answer2 follow-ups

Model answer

Is-a is inheritance: SavingsAccount extends Account, so a savings account can be used anywhere an account is expected. Has-a is composition: Checkout holds a PaymentGateway in a field and calls it.

I prefer composition by default. It depends only on the collaborator's public interface, can be swapped at runtime or in tests, and doesn't inherit implementation details. I use inheritance when the subclass is a true subtype and the parent was designed to be extended.

How I’d say it in the interview

"Is-a is inheritance, has-a is composition. My default is composition, because it couples me to an interface instead of an implementation and lets me swap collaborators. I reach for inheritance only for real subtypes with a parent designed for extension."

Signals depth

Defining is-a through Liskov substitution rather than the English sentence, and naming a concrete failure of inheritance: self-use, constructor calls to overridable methods, or a parent adding methods later.

Red flags

"Inheritance is for code reuse." Reuse alone is the classic wrong reason to extend.

Follow-ups interviewers ask next

  1. What if I say composition is more code, so why bother?

    It is more code: forwarding methods. But the cost is boilerplate you write once, while the cost of a wrong inheritance hierarchy is paid on every change to the parent. And the JIT can inline monomorphic forwarding calls, so runtime cost is usually not the deciding factor.

    1. What if I need polymorphism with composition?

      Put the polymorphism on the collaborator interface: Ticket has a PricingPolicy, Checkout has a PaymentGateway, Cache has an EvictionPolicy. You still get dynamic dispatch, but the axis of change is explicit.

Question 2. ConceptWarm-upAsked very often

Define association, aggregation and composition, with an example of each.

Tests UML relationships, lifecycle

Show answerHide answer2 follow-ups

Model answer

All three are has-a relationships, ordered by how tightly the lifecycles are bound:

  • Association: objects know or use each other, with independent lifecycles. A Doctor and a Patient.
  • Aggregation: a whole groups parts that are shared and can outlive it. A Team and its Players. UML: hollow diamond at the whole.
  • Composition: the whole owns its parts exclusively; they are created by it and die with it. An Order and its OrderLines. UML: filled diamond at the whole.

This diagram is the picture I want in your head: the field shape may look similar, but lifecycle and sharing decide the relationship.

Fig. 1

Association, aggregation and composition are separated by lifecycle ownership and sharing rules.

Association, aggregation and composition are separated by lifecycle ownership and sharing rules.

How I’d say it in the interview

"They're three strengths of has-a. Association is two independent objects that know each other, like doctor and patient. Aggregation is a whole with shared parts that outlive it, like a team and players. Composition is ownership: an order creates its lines and they go when the order goes. Hollow diamond for aggregation, filled for composition, always at the whole."

Signals depth

Saying that lifecycle and sharing decide the relationship, not syntax, and that a composed part belongs to at most one whole at a time.

Red flags

Defining them by code shape: "aggregation is a field, composition is a field created with new." The same field can model any of the three.

Follow-ups interviewers ask next

  1. What if I ask where the diamond goes?

    At the whole. Order ◆--- OrderLine. Candidates often put it at the part, which reverses the meaning.

    1. What if the database has a foreign key with cascade delete?

      That can support composition at persistence level, but it does not prove your Java model has composition. The Java code must still prevent shared mutable parts and route changes through the owning aggregate.

Question 3. ConceptCoreAsked very often

Explain the Liskov substitution principle with an example that compiles but is wrong.

Tests LSP, contracts, subtyping

Show answerHide answer2 follow-ups

Model answer

LSP says objects of a subtype must be usable wherever the parent type is expected, without breaking the parent's promises. A subclass may accept more and guarantee more, never require more or deliver less.

class Account {
    void withdraw(BigDecimal amount) { /* debit */ }
}
class FixedDepositAccount extends Account {
    @Override void withdraw(BigDecimal amount) {
        throw new UnsupportedOperationException("locked until maturity");
    }
}

It compiles, the sentence "a fixed deposit is an account" sounds right, and any caller that does account.withdraw(rent) breaks when given a FixedDepositAccount. Better: a Withdrawable interface that only some account types implement.

How I’d say it in the interview

"Liskov means a subtype has to keep the parent's promises. If FixedDepositAccount extends Account and throws on withdraw, it compiles but every caller that relied on withdraw now breaks. That tells me the hierarchy is wrong; I'd split withdrawal into its own interface."

Signals depth

Phrasing it as preconditions cannot be strengthened and postconditions cannot be weakened, and noticing that UnsupportedOperationException in an override is a common LSP smell.

Red flags

Only reciting Rectangle and Square without explaining which promise breaks.

Follow-ups interviewers ask next

  1. What if the parent's javadoc says withdraw may throw UnsupportedOperationException?

    Then the subclass is not violating the contract, because the contract allowed it. That's exactly how List.add is specified: optional operation. It's legal, but it pushes the check onto every caller, which is why I'd still prefer a separate interface in my own code.

    1. What if the operation is rare, like closing a frozen account?

      A rare operation is still part of the contract if it appears on the base type. Put it on a narrower interface or service method so most account types are not forced to lie.

Question 4. DesignCoreCommonly asked

When is inheritance the right choice over composition?

Tests designing for extension, template method, sealed types

Show answerHide answer2 follow-ups

Model answer

When three things are true: the subclass is a true subtype under LSP, the parent was designed and documented for extension (it documents self-use, exposes protected hooks, and does not call overridable methods from its constructor), and you own both classes so they change together.

Good fits: AbstractList-style skeletal implementations, exception hierarchies, a template method where a final method fixes the algorithm and subclasses fill in steps, and sealed hierarchies with a closed set of subtypes. If a class is not designed for extension, make it final.

How I’d say it in the interview

"When it's a real subtype, the base class is designed for extension, and we own both. Template methods, skeletal implementations like AbstractList, exception hierarchies and sealed types are the classic cases. Anything else I mark final and compose."

Signals depth

Mentioning @implSpec as how the JDK documents self-use, and that calling an overridable method from a constructor runs subclass code before the subclass's fields are initialised.

Red flags

"Never use inheritance." That's a slogan, and interviewers will push back with exceptions.

Follow-ups interviewers ask next

  1. What if a third-party class I need to customise is not final?

    I still wrap it behind my own interface. Extending a class I don't own couples me to its internals across upgrades; one minor version can add a method that bypasses my overrides.

    1. What if the third-party class has protected hooks?

      Then I read its extension contract. If it documents the hooks and self-use, extension may be intended. If not, I treat protected as accidental surface area, not permission.

Question 5. What does it print?CoreAsked very often

What does this print, and why?

Tests self-use, virtual dispatch, fragile base class

Show answerHide answer2 follow-ups

Model answer

class TrackedSkuSet extends HashSet<String> {
    int added = 0;
    @Override public boolean add(String s) { added++; return super.add(s); }
    @Override public boolean addAll(Collection<? extends String> c) {
        added += c.size();
        return super.addAll(c);
    }
}

var s = new TrackedSkuSet();
s.addAll(List.of("A", "B", "C"));
System.out.println(s.added);   // 6

HashSet inherits addAll from AbstractCollection, which loops and calls add(e) on this. That's an invokevirtual, so it dispatches to the override in TrackedSkuSet, and each element is counted twice: 3 in addAll, then 3 more in add.

Here is the dispatch path that causes the double count.

Fig. 1

The inherited addAll method calls add on this, so virtual dispatch lands back in the subclass override.

The inherited addAll method calls add on this, so virtual dispatch lands back in the subclass override.

How I’d say it in the interview

"Six. The inherited addAll calls add for each element, and because that's a virtual call on this, it lands in my override. So I count three in addAll and three again in add. The subclass depends on how the parent uses its own methods."

Signals depth

Distinguishing super.addAll (invokespecial, pinned to the parent) from the parent's internal add call (invokevirtual, dispatched to you), and calling it self-use or the fragile base class problem.

Red flags

"Remove the addAll override." That gives 3 today, but now correctness depends on an implementation detail of the parent.

Follow-ups interviewers ask next

  1. What if I fix it properly?

    Wrap instead of extend: a ForwardingSet<E> implements Set<E> holding a private final Set<E> delegate, and a CountingSet that extends the forwarding class. The inner HashSet calls add on itself, never on the wrapper, so the count is 3, and the wrapper works for TreeSet or any other Set.

    1. What if the wrapped object registers itself as a listener somewhere?

      Then callbacks go to the inner object and skip the wrapper. That's the known blind spot of wrappers, the SELF problem. It's rare for collections, but worth naming for callback-heavy code.

Question 6. DesignCoreCommonly asked

How do you enforce composition in Java, since the language has no ownership?

Tests encapsulation, defensive copies, immutability

Show answerHide answer2 follow-ups

Model answer

By controlling how part references are created and exposed:

  1. The whole creates its parts. order.addLine(sku, qty, price), not order.addLine(lineObjectFromOutside).
  2. Nothing mutable escapes. Return Collections.unmodifiableList(lines) or List.copyOf(lines), and make parts immutable, like record OrderLine(...), or return copies.
  3. Mutations go through the whole. order.changeQuantity(sku, 3), so the whole can re-check invariants like "total must be positive".

How I’d say it in the interview

"Java won't do it for me, so the whole creates the parts, keeps them private, exposes read-only views or immutable records, and routes every change through its own methods so invariants stay in one place."

Signals depth

Noticing that unmodifiableList alone is not enough if the elements are mutable, and mentioning JPA's cascade = ALL with orphanRemoval = true as the persistence-level equivalent.

Red flags

Returning the internal ArrayList from a getter while calling it composition.

Follow-ups interviewers ask next

  1. What if a caller keeps a reference to one OrderLine after the Order is gone?

    That line stays alive. The GC frees objects by reachability, not ownership, so any outside reference extends the part's life. With immutable parts it's harmless; with mutable parts it's a design bug.

    1. What if I expose List.copyOf(lines)?

      That protects the list structure, not necessarily the elements. If OrderLine is mutable, callers can still mutate the object they received. Prefer immutable parts for composed children.

Question 7. DebuggingCoreCommonly asked

Your order service started accepting negative totals after a refactor exposed getLines(). Walk me through it.

Tests representation exposure, composition invariants, defensive copies

Show answerHide answer2 follow-ups

Model answer

This smells like representation exposure. The service probably returned its internal List<OrderLine> or mutable OrderLine objects, so another part of the code changed quantity or price without going through Order. The aggregate's invariant checks were bypassed.

I would inspect the getter first, then search for mutations on the returned list or line objects. The fix is to keep the list private, expose List.copyOf(lines), make OrderLine immutable, and provide intention-revealing methods like changeQuantity(sku, qty) that revalidate the order.

How I’d say it in the interview

"I'd suspect the refactor leaked the internals. If callers can mutate the line list or mutable line objects, they can bypass the Order methods that enforce totals. I'd make the children immutable, return a copy or read-only view, and route mutations through Order."

Signals depth

Separating three risks: mutating the collection, mutating the child objects, and holding an old child reference after the parent changes.

Red flags

"Add a validation before saving." That may catch some bad states late, but it does not fix the broken ownership boundary.

Follow-ups interviewers ask next

  1. What if the getter returns Collections.unmodifiableList(lines)?

    Then callers cannot add or remove from the list, but they can still mutate any mutable OrderLine inside it. And because it is a live view, internal changes to lines appear through the old view.

    1. What if this is a JPA entity and the ORM needs mutable collections?

      Keep the entity collection protected by domain methods and avoid exposing it directly. You can return a defensive copy for reads while letting JPA manage the field internally.

Question 8. DesignCoreCommonly asked

A doctor can have many patients and a patient many doctors. How would you model it in Java?

Tests many-to-many, bidirectional links, association class

Show answerHide answer2 follow-ups

Model answer

If the link carries no data, two Set fields with helpers that keep both sides in sync:

void addPatient(Patient p) { if (patients.add(p)) p.addDoctor(this); }
void addDoctor(Doctor d)   { if (doctors.add(d)) d.addPatient(this); }

Set.add returns false the second time, which stops the recursion. In practice the link almost always has data: date, reason, fee. I would make it an association class: record Appointment(Doctor doctor, Patient patient, LocalDateTime at), stored in a repository. Doctor and Patient no longer reference each other, and the many-to-many becomes two one-to-many links, the same shape as a join table.

How I’d say it in the interview

"For a pure link, two sets kept in sync by helper methods. But a doctor-patient link usually has a time and a reason, so I'd model Appointment as its own class and drop the direct references. It's easier to keep consistent and it maps straight to a join table."

Signals depth

Defaulting to unidirectional links and adding back-pointers only for a real navigation need.

Red flags

Bidirectional fields with no sync logic, so the two sides can disagree.

Follow-ups interviewers ask next

  1. What if both classes have a generated toString or equals that includes the other side?

    Infinite recursion and a StackOverflowError on the first println or hashCode. The same happens in JSON serialization. Exclude the back-pointer, use @JsonManagedReference/@JsonBackReference, or don't keep it at all.

    1. What if Appointment has its own lifecycle?

      Then it is no longer a throwaway link. Give it an identity if the domain needs cancellation, billing, audit history, or status transitions over time.

Question 9. DebuggingCoreCommonly asked

Your appointment API started throwing StackOverflowError after adding bidirectional Doctor and Patient links for JSON. Walk me through it.

Tests bidirectional association, recursive serialization, equals and toString

Show answerHide answer2 follow-ups

Model answer

The likely cause is a cycle in the object graph: Doctor has patients, each Patient has doctors, and the serializer walks both directions until the stack blows. The same problem appears in generated toString, equals or hashCode if they include back-pointers.

I would confirm from the stack trace: repeated serializer frames alternating between Doctor and Patient. The fix is to remove one direction if we do not need it, serialize DTOs instead of entities, or configure the serializer with managed/back references, identity references, or ignored fields.

How I’d say it in the interview

"I'd look for a bidirectional graph that the serializer is walking forever. Doctor points to Patient and Patient points back to Doctor. My preferred fix is a DTO or a unidirectional model; annotations are a fallback when the domain really needs both links."

Signals depth

Connecting the same graph cycle to JSON, logging, equals, hashCode and debugger views, not treating it as only a Jackson problem.

Red flags

"Increase the stack size." That hides the symptom and leaves the infinite traversal.

Follow-ups interviewers ask next

  1. What if the team adds @JsonIgnore on one side?

    That can stop the recursion, but it is a serialization decision embedded in the domain object. I prefer DTOs at API boundaries if the response shape is not the domain shape.

    1. What if lazy JPA collections are involved?

      Serializing entities can also trigger unexpected database queries or LazyInitializationException. DTOs make the fetch plan and response shape explicit.

Question 10. DebuggingSeniorSometimes asked

Your order service started leaking memory after a listener refactor used non-static inner classes. Walk me through it.

Tests reachability, GC roots, inner class outer reference

Show answerHide answer2 follow-ups

Model answer

No collector understands "composition" or "aggregation" as domain concepts. HotSpot traces from GC roots: thread stacks, static fields, JNI handles and similar. Anything reachable stays alive.

A non-static inner listener has a hidden reference to its outer instance (this$0). If that listener is stored in a static list, event bus, scheduler, or long-lived cache, it keeps the outer OrderService reachable too. That can retain repositories, caches and recent order graphs long after the request is done.

How I’d say it in the interview

"I'd draw reachability, not ownership. A non-static inner class points back to the outer object. If a long-lived registry holds the listener, it also holds the service and whatever the service references. I'd unregister it or make the listener static and pass only the state it needs."

Signals depth

Naming GC roots concretely, and knowing cycles are not a problem for tracing collectors if no root reaches the cycle.

Red flags

"Set fields to null in the parent so the GC can free children." It is unnecessary for unreachable graphs and insufficient for reachable ones.

Follow-ups interviewers ask next

  1. What if the listener and outer service reference each other in a cycle but nothing else references them?

    They are collected. Cycles only matter to reference-counting schemes. HotSpot's tracing collectors reclaim an unreachable cycle like any other garbage.

    1. What if the cache key is weak but the value points back to the key?

      That can still retain more than you expect depending on the map and reference shape. For listener registries and caches, I prefer explicit unregister or bounded lifetime over clever weak-reference designs.

Question 11. ScenarioCoreCommonly asked

Your parking-lot release added EV charging and member discounts, and pricing bugs appeared across weekend tickets. What would you change?

Tests strategy pattern, class explosion, LLD

Show answerHide answer2 follow-ups

Model answer

Pricing is behaviour that varies independently of the ticket, so it should not be a subclass. I would give Ticket a PricingStrategy field, for example Money price(Duration parked, VehicleType type), and implement hourly, daily, weekend, member discount and EV charging rules separately.

Subclassing multiplies with every new dimension: WeekendMemberEvTicket, DailyMemberEvTicket, and so on. With composition, a new rule is one class, and decorators or a rule pipeline combine them at runtime.

How I’d say it in the interview

"Pricing varies on its own axis, so I'd compose it: Ticket has a PricingStrategy. New pricing rules become new strategy classes, not new tickets, and I avoid the combinatorial explosion when membership and EV charging show up."

Signals depth

Asking which parts are expected to change, and keeping a small sealed hierarchy or enum for things that genuinely are a closed set, such as vehicle types.

Red flags

Adding a type field plus a big switch in Ticket as the fix. It moves the explosion into one method.

Follow-ups interviewers ask next

  1. What if pricing depends on both vehicle type and duration?

    Still a strategy. It receives both as parameters, or you compose strategies: a base rate table keyed by vehicle type, wrapped by a weekend surcharge and member discount.

    1. What if product wants rules changed without redeploying?

      Then code-level strategies may not be enough. I would discuss a validated rule table or configuration-driven policy, but still keep Ticket separate from pricing.

Question 12. DebuggingSeniorCommonly asked

Your service upgraded a shared library, and a subclassed collection started double-counting events in production. Walk me through it.

Tests fragile base class, self-use, library upgrade risk

Show answerHide answer2 follow-ups

Model answer

I would suspect a fragile base class bug. The subclass overrides methods like add, put or record, while the parent implementation calls those methods internally. After the library upgrade, a new parent method path may call the override more often, or skip it entirely.

The first check is the stack trace or profiler sample around the counter increment. If the call enters super.addAll and then re-enters the subclass's add, it is the same self-use problem as the HashSet example. The fix is not to chase every parent method; wrap the collection behind a narrow interface and count at the boundary you own.

How I’d say it in the interview

"A library upgrade changing a parent class can break subclasses that relied on undocumented internals. I'd look for parent self-use calling back into our override. The durable fix is composition: wrap the collection and count in our wrapper, not by inheriting from a concrete collection."

Signals depth

Mentioning that public inheritance freezes the superclass contract for subclasses and callers, so even a compatible method addition can change subclass behaviour.

Red flags

"Pin the old library version forever." Pinning may buy time, but it does not remove the design risk.

Follow-ups interviewers ask next

  1. What if we cannot replace the subclass immediately?

    Add focused regression tests around the observed paths, override the specific new path if safe, and isolate construction of that type so the wrapper migration is small. Treat it as a containment step, not the final design.

    1. What if the parent documents its self-use with @implSpec?

      Then extending it may be acceptable if the subclass follows that contract. The problem is extending concrete classes that were not designed and documented for extension.

Question 13. DebuggingSeniorSometimes asked

Your config service stores Properties, then starts failing after one code path calls raw put. What's wrong with that API design?

Tests LSP in the JDK, leaky inheritance, legacy collections

Show answerHide answer2 follow-ups

Model answer

Properties extends Hashtable<Object,Object>, so it inherits put(Object, Object). That means code can store a non-String key or value even though Properties is meant to represent string properties. Later, getProperty returns null for that key, and store() can fail because it expects strings.

This is inheritance for reuse leaking the wrong API. A better design would compose a Map<String, String> privately and expose only string-based operations. The same issue appears in Stack extends Vector: a stack accidentally exposes random access and insertion methods that violate LIFO.

How I’d say it in the interview

"Properties inherits Hashtable's Object-based put, so callers can insert values that break the Properties abstraction. It should have wrapped a Map<String, String>. Stack has the same smell: it inherits Vector methods that let callers violate stack order."

Signals depth

Knowing these are kept for backward compatibility, and that Vector and Hashtable synchronize every method, adding lock overhead nobody asked for in modern code.

Red flags

"It's fine because it's in the JDK." The JDK contains old APIs that are lessons, not patterns to copy.

Follow-ups interviewers ask next

  1. What if I ask why the JDK doesn't fix them now?

    Changing a superclass is a binary-incompatible change for existing callers and subclasses. Once inheritance is public, it's part of the API forever, which is the strongest argument for getting it right the first time.

    1. What if I need a stack today?

      Use Deque<E> stack = new ArrayDeque<>(); with push, pop and peek. Program to Deque, not the legacy Stack class.

Lessons in Object relationships