Udaya Yadav

From If-Chains to Design Patterns

Notes on how a property-search filter engine evolved, kept mostly for future-me. Each step is the direct consequence of a problem the previous step created — not a pattern applied for its own sake.

1. The naive version: one class, if-chains

class Property {
    String id;
    double price;
    double areaSqft;
    String bhk;
    String propertyType;    // "APARTMENT", "VILLA"
    String furnishing;      // "FURNISHED", "SEMI_FURNISHED", "UNFURNISHED"
    Set<String> amenities;  // {"GYM", "POOL", "PARKING"}
    GeoPoint location;
    String postedBy;        // "OWNER", "AGENT"
}

class BookingPlatform {
    List<Property> properties = new ArrayList<>();

    List<Property> search(double minPrice, double maxPrice, String bhk, String amenity) {
        List<Property> results = new ArrayList<>();
        for (Property p : properties) {
            if (p.price < minPrice || p.price > maxPrice) continue;
            if (!p.bhk.equals(bhk)) continue;
            if (!p.amenities.contains(amenity)) continue;
            results.add(p);
        }
        return results;
    }
}

Every step after this one exists to remove one of these constraints without reintroducing the others.

2. Filters as Strategy objects — but field access still hardcoded

interface Filter {
    boolean isSatisfiedBy(Property property);
}

class PriceRangeFilter implements Filter {
    private final double min;
    private final double max;

    PriceRangeFilter(double min, double max) {
        this.min = min;
        this.max = max;
    }

    @Override
    public boolean isSatisfiedBy(Property p) {
        return p.price >= min && p.price <= max;   // hardcoded field access
    }
}

class AreaRangeFilter implements Filter {
    private final double min;
    private final double max;

    AreaRangeFilter(double min, double max) {
        this.min = min;
        this.max = max;
    }

    @Override
    public boolean isSatisfiedBy(Property p) {
        return p.areaSqft >= min && p.areaSqft <= max;   // identical logic, different field
    }
}

class GeoRadiusFilter implements Filter {
    private final GeoPoint center;
    private final double radiusKm;

    GeoRadiusFilter(GeoPoint center, double radiusKm) {
        this.center = center;
        this.radiusKm = radiusKm;
    }

    @Override
    public boolean isSatisfiedBy(Property p) {
        return distanceKm(center, p.location) <= radiusKm;   // hardcoded call to p.location
    }
}

3. Field + FieldExtractor — decoupling "which field" from "how to read it"

enum Field { PRICE, AREA, BHK, PROPERTY_TYPE, FURNISHING, AMENITIES, LOCATION, POSTED_BY }

interface FieldExtractor<T> {
    T extract(Property property);
}

final class Extractors {
    private Extractors() {}

    static FieldExtractor<Double> numeric(Field field) {
        return switch (field) {
            case PRICE -> Property::getPrice;
            case AREA  -> Property::getAreaSqft;
            default -> throw new IllegalArgumentException("Not a numeric field: " + field);
        };
    }

    static FieldExtractor<String> categorical(Field field) {
        return switch (field) {
            case BHK           -> Property::getBhk;
            case FURNISHING    -> Property::getFurnishing;
            case PROPERTY_TYPE -> Property::getPropertyType;
            case POSTED_BY     -> Property::getPostedBy;
            default -> throw new IllegalArgumentException("Not a categorical field: " + field);
        };
    }

    static FieldExtractor<Set<String>> multiValued(Field field) {
        return switch (field) {
            case AMENITIES -> Property::getAmenities;
            default -> throw new IllegalArgumentException("Not a multi-valued field: " + field);
        };
    }

    static FieldExtractor<GeoPoint> geo(Field field) {
        return switch (field) {
            case LOCATION -> Property::getLocation;
            default -> throw new IllegalArgumentException("Not a geo field: " + field);
        };
    }
}
class RangeFilter implements Filter {
    private final double min, max;
    private final FieldExtractor<Double> extractor;

    RangeFilter(double min, double max, FieldExtractor<Double> extractor) {
        this.min = min; this.max = max; this.extractor = extractor;
    }

    @Override
    public boolean isSatisfiedBy(Property p) {
        double v = extractor.extract(p);
        return v >= min && v <= max;
    }
}

class MultiSelectFilter implements Filter {
    private final Set<String> allowedValues;
    private final FieldExtractor<String> extractor;

    MultiSelectFilter(Set<String> allowedValues, FieldExtractor<String> extractor) {
        this.allowedValues = allowedValues; this.extractor = extractor;
    }

    @Override
    public boolean isSatisfiedBy(Property p) {
        return allowedValues.contains(extractor.extract(p));
    }
}

class GeoRadiusFilter implements Filter {
    private final GeoPoint center;
    private final double radiusKm;
    private final DistanceCalculator distanceCalculator;
    private final FieldExtractor<GeoPoint> extractor;

    GeoRadiusFilter(GeoPoint center, double radiusKm, DistanceCalculator distanceCalculator,
                     FieldExtractor<GeoPoint> extractor) {
        this.center = center; this.radiusKm = radiusKm;
        this.distanceCalculator = distanceCalculator; this.extractor = extractor;
    }

    @Override
    public boolean isSatisfiedBy(Property p) {
        return distanceCalculator.distanceKm(center, extractor.extract(p)) <= radiusKm;
    }
}

4. Criterion as one class with nullable fields — then the NPE trap

class FilterCriterion {
    Field field;
    FilterType type;
    Double min, max;        // set only for RANGE
    Set<String> values;     // set only for MULTI_SELECT
    Set<String> amenities;  // set only for AMENITIES
    GeoPoint center;         // set only for GEO_RADIUS
    double radiusKm;         // set only for GEO_RADIUS
}
enum FilterType { RANGE, MULTI_SELECT, AMENITIES, GEO_RADIUS }

interface FilterCriterion {
    FilterType getType();
    Field getField();

    static FilterCriterion range(Field field, double min, double max) {
        return new RangeCriterion(field, min, max);
    }
    static FilterCriterion multiSelect(Field field, Set<String> values) {
        return new MultiSelectCriterion(field, values);
    }
    static FilterCriterion amenities(Set<String> values) {
        return new AmenitiesCriterion(values);
    }
    static FilterCriterion geoRadius(GeoPoint center, double radiusKm) {
        return new GeoRadiusCriterion(center, radiusKm);
    }
}

class RangeCriterion implements FilterCriterion {
    private final Field field;
    private final double min, max;

    RangeCriterion(Field field, double min, double max) {
        if (min > max) throw new IllegalArgumentException("min must be <= max");
        this.field = field; this.min = min; this.max = max;
    }

    @Override public FilterType getType() { return FilterType.RANGE; }
    @Override public Field getField() { return field; }
    public double getMin() { return min; }
    public double getMax() { return max; }
}

class MultiSelectCriterion implements FilterCriterion {
    private final Field field;
    private final Set<String> values;

    MultiSelectCriterion(Field field, Set<String> values) {
        if (values == null || values.isEmpty()) throw new IllegalArgumentException("values required");
        this.field = field; this.values = Set.copyOf(values);
    }

    @Override public FilterType getType() { return FilterType.MULTI_SELECT; }
    @Override public Field getField() { return field; }
    public Set<String> getValues() { return values; }
}

class AmenitiesCriterion implements FilterCriterion {
    private final Set<String> requiredAmenities;

    AmenitiesCriterion(Set<String> requiredAmenities) {
        if (requiredAmenities == null || requiredAmenities.isEmpty())
            throw new IllegalArgumentException("requiredAmenities required");
        this.requiredAmenities = Set.copyOf(requiredAmenities);
    }

    @Override public FilterType getType() { return FilterType.AMENITIES; }
    @Override public Field getField() { return Field.AMENITIES; }   // always this field — no ambiguity to resolve
    public Set<String> getRequiredAmenities() { return requiredAmenities; }
}

class GeoRadiusCriterion implements FilterCriterion {
    private final GeoPoint center;
    private final double radiusKm;

    GeoRadiusCriterion(GeoPoint center, double radiusKm) {
        if (center == null) throw new IllegalArgumentException("center required");
        if (radiusKm <= 0) throw new IllegalArgumentException("radiusKm must be > 0");
        this.center = center; this.radiusKm = radiusKm;
    }

    @Override public FilterType getType() { return FilterType.GEO_RADIUS; }
    @Override public Field getField() { return Field.LOCATION; }
    public GeoPoint getCenter() { return center; }
    public double getRadiusKm() { return radiusKm; }
}

5. FilterFactory — bridging descriptive criteria to executable filters

interface FilterFactory {
    Filter create(FilterCriterion criterion);
}

class DefaultFilterFactory implements FilterFactory {
    private final DistanceCalculator distanceCalculator;

    DefaultFilterFactory(DistanceCalculator distanceCalculator) {
        this.distanceCalculator = distanceCalculator;
    }

    @Override
    public Filter create(FilterCriterion criterion) {
        return switch (criterion.getType()) {
            case RANGE -> {
                RangeCriterion c = (RangeCriterion) criterion;
                yield new RangeFilter(c.getMin(), c.getMax(), Extractors.numeric(c.getField()));
            }
            case MULTI_SELECT -> {
                MultiSelectCriterion c = (MultiSelectCriterion) criterion;
                yield new MultiSelectFilter(c.getValues(), Extractors.categorical(c.getField()));
            }
            case AMENITIES -> {
                AmenitiesCriterion c = (AmenitiesCriterion) criterion;
                yield new AmenitiesFilter(c.getRequiredAmenities(), Extractors.multiValued(c.getField()));
            }
            case GEO_RADIUS -> {
                GeoRadiusCriterion c = (GeoRadiusCriterion) criterion;
                yield new GeoRadiusFilter(c.getCenter(), c.getRadiusKm(), distanceCalculator,
                                            Extractors.geo(c.getField()));
            }
        };
    }
}

6. Composite + Builder — nesting AND/OR, but the request stayed flat

enum LogicalOperator { AND, OR }

class CompositeFilter implements Filter {
    private final LogicalOperator operator;
    private final List<Filter> children = new ArrayList<>();

    CompositeFilter(LogicalOperator operator) {
        this.operator = operator;
    }

    CompositeFilter add(Filter filter) {
        children.add(filter);
        return this;
    }

    @Override
    public boolean isSatisfiedBy(Property property) {
        return switch (operator) {
            case AND -> children.stream().allMatch(f -> f.isSatisfiedBy(property));
            case OR  -> children.stream().anyMatch(f -> f.isSatisfiedBy(property));
        };
    }
}
class FilterRequestBuilder {
    private final FilterFactory filterFactory;
    private final CompositeFilter root;

    FilterRequestBuilder(FilterFactory filterFactory) {
        this(filterFactory, LogicalOperator.AND);   // default root: AND
    }

    FilterRequestBuilder(FilterFactory filterFactory, LogicalOperator rootOperator) {
        this.filterFactory = filterFactory;
        this.root = new CompositeFilter(rootOperator);
    }

    FilterRequestBuilder withCriterion(FilterCriterion criterion) {
        root.add(filterFactory.create(criterion));
        return this;
    }

    FilterRequestBuilder withCriteria(List<FilterCriterion> criteria) {
        criteria.forEach(this::withCriterion);
        return this;
    }

    FilterRequestBuilder withGroup(Filter group) {   // attach a pre-built sub-tree
        root.add(group);
        return this;
    }

    Filter build() {
        return root;
    }

    static Filter group(FilterFactory filterFactory, LogicalOperator operator, List<FilterCriterion> criteria) {
        CompositeFilter group = new CompositeFilter(operator);
        criteria.forEach(c -> group.add(filterFactory.create(c)));
        return group;
    }
}
Filter cheap = FilterRequestBuilder.group(factory, LogicalOperator.AND, List.of(
        FilterCriterion.multiSelect(Field.BHK, Set.of("1")),
        FilterCriterion.range(Field.PRICE, 0, 2_500_000)));
Filter luxury = FilterRequestBuilder.group(factory, LogicalOperator.AND, List.of(
        FilterCriterion.multiSelect(Field.BHK, Set.of("4+")),
        FilterCriterion.amenities(Set.of("GARDEN"))));

Filter orOfGroups = new FilterRequestBuilder(factory, LogicalOperator.OR)
        .withGroup(cheap)
        .withGroup(luxury)
        .build();

platform.search(orOfGroups);   // via the raw-Filter overload, not SearchRequest

7. Hardening Property — immutability, defensive copies, closed-value enums, and safe concurrent reads

Several independent hardening passes, grouped here because they share one theme: making it structurally hard to end up with corrupted or inconsistent state.

Immutability + defensive copying

class Property {
    private final String id;
    private final double price;
    private final double areaSqft;
    private final String bhk;
    private final PropertyType propertyType;
    private final Furnishing furnishing;
    private final Set<Amenity> amenities;
    private final GeoPoint location;
    private final PostedBy postedBy;

    Property(String id, double price, double areaSqft, String bhk, PropertyType propertyType,
             Furnishing furnishing, Set<Amenity> amenities, GeoPoint location, PostedBy postedBy) {
        this.id = id;
        this.price = price;
        this.areaSqft = areaSqft;
        this.bhk = bhk;
        this.propertyType = propertyType;
        this.furnishing = furnishing;
        this.amenities = Collections.unmodifiableSet(EnumSet.copyOf(amenities));   // <- defensive copy
        this.location = location;
        this.postedBy = postedBy;
    }
    // getters only — no setters, every field final
}

Raw strings to enums for closed categories

static FieldExtractor<String> categorical(Field field) {
    return switch (field) {
        case BHK           -> Property::getBhk;               // still a raw string
        case FURNISHING    -> p -> p.getFurnishing().name();    // enum -> String bridge
        case PROPERTY_TYPE -> p -> p.getPropertyType().name();
        case POSTED_BY     -> p -> p.getPostedBy().name();
        default -> throw new IllegalArgumentException("Not a categorical field: " + field);
    };
}

static FieldExtractor<Set<Amenity>> multiValued(Field field) {
    return switch (field) {
        case AMENITIES -> Property::getAmenities;   // now Set<Amenity>, not Set<String>
        default -> throw new IllegalArgumentException("Not a multi-valued field: " + field);
    };
}

Thread-safe reads

class BookingPlatform {
    private final List<Property> properties = new CopyOnWriteArrayList<>();   // not ArrayList
    // ...
}

9. SearchNode — closing the last gap: nested trees as pure data

sealed interface SearchNode permits CriterionNode, GroupNode {
    static SearchNode criterion(FilterCriterion criterion) {
        return new CriterionNode(criterion);
    }
    static SearchNode group(LogicalOperator operator, List<SearchNode> children) {
        return new GroupNode(operator, children);
    }
}

final class CriterionNode implements SearchNode {
    private final FilterCriterion criterion;

    CriterionNode(FilterCriterion criterion) {
        if (criterion == null) throw new IllegalArgumentException("criterion required");
        this.criterion = criterion;
    }

    FilterCriterion getCriterion() { return criterion; }
}

final class GroupNode implements SearchNode {
    private final LogicalOperator operator;
    private final List<SearchNode> children;

    GroupNode(LogicalOperator operator, List<SearchNode> children) {
        if (operator == null) throw new IllegalArgumentException("operator required");
        if (children == null || children.isEmpty()) throw new IllegalArgumentException("children required");
        this.operator = operator;
        this.children = List.copyOf(children);
    }

    LogicalOperator getOperator() { return operator; }
    List<SearchNode> getChildren() { return children; }
}
final class SearchRequestCompiler {
    private SearchRequestCompiler() {}

    static Filter compile(SearchNode node, FilterFactory filterFactory) {
        return switch (node) {
            case CriterionNode c -> filterFactory.create(c.getCriterion());
            case GroupNode g -> {
                CompositeFilter composite = new CompositeFilter(g.getOperator());
                g.getChildren().forEach(child -> composite.add(compile(child, filterFactory)));
                yield composite;
            }
        };
    }
}
class SearchRequest {
    private final SearchNode root;

    SearchRequest(SearchNode root) { this.root = root; }

    SearchNode getRoot() { return root; }

    static SearchRequest allOf(FilterCriterion... criteria) {
        return new SearchRequest(SearchNode.group(LogicalOperator.AND, toNodes(criteria)));
    }
    static SearchRequest anyOf(FilterCriterion... criteria) {
        return new SearchRequest(SearchNode.group(LogicalOperator.OR, toNodes(criteria)));
    }
    private static List<SearchNode> toNodes(FilterCriterion... criteria) {
        return Arrays.stream(criteria).map(SearchNode::criterion).toList();
    }
}

class BookingPlatform {
    List<Property> search(SearchRequest request) {
        Filter filter = SearchRequestCompiler.compile(request.getRoot(), filterFactory);
        return filterEngine.apply(filter, properties);
    }
}
SearchNode cheap = SearchNode.group(LogicalOperator.AND, List.of(
        SearchNode.criterion(FilterCriterion.multiSelect(Field.BHK, Set.of("1"))),
        SearchNode.criterion(FilterCriterion.range(Field.PRICE, 0, 2_500_000))));
SearchNode luxury = SearchNode.group(LogicalOperator.AND, List.of(
        SearchNode.criterion(FilterCriterion.multiSelect(Field.BHK, Set.of("4+"))),
        SearchNode.criterion(FilterCriterion.amenities(Set.of(Amenity.GARDEN)))));

SearchRequest request = new SearchRequest(SearchNode.group(LogicalOperator.OR, List.of(cheap, luxury)));
platform.search(request);   // one overload, same as any flat query — no Filter or builder in sight

10. Negation, and sealing FilterCriterion for real

Two separate additions, both closing gaps flagged earlier rather than opening new ones.

10a. NOT — a third case where the tree wasn't complete

Executable layer — a fourth Filter implementation, sitting right alongside RangeFilter/MultiSelectFilter/AmenitiesFilter/GeoRadiusFilter/CompositeFilter:

public class NotFilter implements Filter {
    private final Filter child;

    public NotFilter(Filter child) {
        this.child = child;
    }

    @Override
    public boolean isSatisfiedBy(Property property) {
        return !child.isSatisfiedBy(property);
    }
}

Descriptive layer — SearchNode grows a third permitted subtype:

public sealed interface SearchNode permits CriterionNode, GroupNode, NotNode {
    static SearchNode criterion(FilterCriterion criterion) { return new CriterionNode(criterion); }
    static SearchNode group(LogicalOperator operator, List<SearchNode> children) { return new GroupNode(operator, children); }
    static SearchNode not(SearchNode child) { return new NotNode(child); }
}

public final class NotNode implements SearchNode {
    private final SearchNode child;

    NotNode(SearchNode child) {
        if (child == null) throw new IllegalArgumentException("child must not be null");
        this.child = child;
    }

    public SearchNode getChild() { return child; }
}

SearchRequestCompiler picks up the new case:

public static Filter compile(SearchNode node, FilterFactory filterFactory) {
    return switch (node) {
        case CriterionNode c -> filterFactory.create(c.getCriterion());
        case GroupNode g -> {
            CompositeFilter composite = new CompositeFilter(g.getOperator());
            g.getChildren().forEach(child -> composite.add(compile(child, filterFactory)));
            yield composite;
        }
        case NotNode n -> new NotFilter(compile(n.getChild(), filterFactory));
    };
}
// apartments NOT posted by an agent
SearchRequest request = new SearchRequest(
        SearchNode.group(LogicalOperator.AND, List.of(
                SearchNode.criterion(FilterCriterion.multiSelect(Field.PROPERTY_TYPE, Set.of("APARTMENT"))),
                SearchNode.not(SearchNode.criterion(FilterCriterion.multiSelect(Field.POSTED_BY, Set.of("AGENT"))))
        ))
);
platform.search(request);

10b. Sealing FilterCriterion

public sealed interface FilterCriterion
        permits RangeCriterion, MultiSelectCriterion, AmenitiesCriterion, GeoRadiusCriterion {
    FilterType getType();
    Field getField();
    // ...static factories, unchanged
}
public final class RangeCriterion implements FilterCriterion { /* unchanged body */ }
public final class MultiSelectCriterion implements FilterCriterion { /* unchanged body */ }
public final class AmenitiesCriterion implements FilterCriterion { /* unchanged body */ }
public final class GeoRadiusCriterion implements FilterCriterion { /* unchanged body */ }

11. Role-based access — a Protection Proxy in front of the platform

public enum Role {
    BUYER,
    SELLER
}
public enum Operation {
    POST_PROPERTY,
    DELETE_PROPERTY,
    BUY_PROPERTY,
    SEARCH_PROPERTY
}
public class User {
    private final String id;
    private final String name;
    private final Set<Role> roles;
    private Role activeProfile;

    public User(String id, String name, Set<Role> roles, Role activeProfile) {
        if (roles == null || roles.isEmpty()) {
            throw new IllegalArgumentException("roles must not be null or empty");
        }
        this.id = id;
        this.name = name;
        this.roles = Collections.unmodifiableSet(EnumSet.copyOf(roles));
        switchProfile(activeProfile);
    }

    public String getId() { return id; }
    public String getName() { return name; }
    public Set<Role> getRoles() { return roles; }
    public Role getActiveProfile() { return activeProfile; }

    public void switchProfile(Role newProfile) {
        if (!roles.contains(newProfile)) {
            throw new IllegalArgumentException(name + " does not hold role " + newProfile);
        }
        this.activeProfile = newProfile;
    }
}
public interface AccessPolicy {
    boolean isAllowed(Role role, Operation operation);
}
public class RoleAccessPolicy implements AccessPolicy {
    private final Map<Role, Set<Operation>> permissions;

    public RoleAccessPolicy(Map<Role, Set<Operation>> permissions) {
        this.permissions = new EnumMap<>(permissions);
    }

    @Override
    public boolean isAllowed(Role role, Operation operation) {
        return permissions.getOrDefault(role, Set.of()).contains(operation);
    }

    public static RoleAccessPolicy defaultPolicy() {
        Map<Role, Set<Operation>> matrix = new EnumMap<>(Role.class);
        matrix.put(Role.SELLER, EnumSet.of(Operation.POST_PROPERTY, Operation.DELETE_PROPERTY, Operation.SEARCH_PROPERTY));
        matrix.put(Role.BUYER, EnumSet.of(Operation.BUY_PROPERTY, Operation.SEARCH_PROPERTY));
        return new RoleAccessPolicy(matrix);
    }
}
public interface PropertyService {
    void postProperty(Property property, User actor);
    void deleteProperty(String propertyId, User actor);
    void buyProperty(String propertyId, User actor);
    List<Property> search(SearchRequest request, User actor);
}
public class AuthorizingPropertyService implements PropertyService {
    private final PropertyService delegate;
    private final AccessPolicy accessPolicy;

    public AuthorizingPropertyService(PropertyService delegate, AccessPolicy accessPolicy) {
        this.delegate = delegate;
        this.accessPolicy = accessPolicy;
    }

    @Override
    public void postProperty(Property property, User actor) {
        authorize(actor, Operation.POST_PROPERTY);
        delegate.postProperty(property, actor);
    }

    // deleteProperty / buyProperty / search follow the same shape:
    // authorize(actor, matching Operation) then delegate.

    private void authorize(User actor, Operation operation) {
        if (!accessPolicy.isAllowed(actor.getActiveProfile(), operation)) {
            throw new AccessDeniedException(
                    actor.getName() + " (acting as " + actor.getActiveProfile() + ") is not allowed to " + operation);
        }
    }
}

12. Listing lifecycle — the State pattern, and a class boundary worth noticing

public enum ListingStatus {
    DRAFT,
    AVAILABLE,
    UNDER_OFFER,
    SOLD,
    WITHDRAWN
}
public sealed interface ListingState permits Draft, Available, UnderOffer, Sold, Withdrawn {
    ListingStatus getStatus();

    default void publish(Listing listing) { throw invalid("publish"); }
    default void initiatePurchase(Listing listing, User buyer) { throw invalid("initiatePurchase"); }
    default void confirmPurchase(Listing listing) { throw invalid("confirmPurchase"); }
    default void cancelPurchase(Listing listing) { throw invalid("cancelPurchase"); }
    default void withdraw(Listing listing) { throw invalid("withdraw"); }

    private InvalidListingTransitionException invalid(String action) {
        return new InvalidListingTransitionException(action + " is not valid from " + getStatus());
    }
}
public final class Available implements ListingState {
    @Override
    public ListingStatus getStatus() { return ListingStatus.AVAILABLE; }

    @Override
    public void initiatePurchase(Listing listing, User buyer) {
        listing.setBuyer(buyer);
        listing.transitionTo(new UnderOffer());
    }

    @Override
    public void withdraw(Listing listing) { listing.transitionTo(new Withdrawn()); }
}

public final class UnderOffer implements ListingState {
    @Override
    public ListingStatus getStatus() { return ListingStatus.UNDER_OFFER; }

    @Override
    public void confirmPurchase(Listing listing) { listing.transitionTo(new Sold()); }

    @Override
    public void cancelPurchase(Listing listing) {
        listing.clearBuyer();
        listing.transitionTo(new Available());
    }
    // no withdraw() override — inherited "invalid" default applies
}
public class Listing {
    private final Property property;
    private ListingState state;
    private User buyer;

    public Listing(Property property) {
        this.property = property;
        this.state = new Draft();
    }

    public synchronized ListingStatus getStatus() { return state.getStatus(); }
    public synchronized void publish() { state.publish(this); }
    public synchronized void initiatePurchase(User buyer) { state.initiatePurchase(this, buyer); }
    public synchronized void confirmPurchase() { state.confirmPurchase(this); }
    public synchronized void cancelPurchase() { state.cancelPurchase(this); }
    public synchronized void withdraw() { state.withdraw(this); }

    // package-private — only ListingState implementations may call these
    void transitionTo(ListingState newState) { this.state = newState; }
    void setBuyer(User buyer) { this.buyer = buyer; }
    void clearBuyer() { this.buyer = null; }
}

13. Property alerts — the Observer pattern, reusing step 9's compiler for free

public interface PropertyPublishedListener {
    void onPropertyPublished(Property property);
}
public class PropertyAlertService {
    private final List<PropertyPublishedListener> listeners = new CopyOnWriteArrayList<>();

    public void subscribe(PropertyPublishedListener listener) { listeners.add(listener); }
    public void unsubscribe(PropertyPublishedListener listener) { listeners.remove(listener); }

    public void publish(Property property) {
        for (PropertyPublishedListener listener : listeners) {
            try {
                listener.onPropertyPublished(property);
            } catch (RuntimeException e) {
                System.out.println("Listener failed, continuing: " + e.getMessage());
            }
        }
    }
}
public class SavedSearchAlertListener implements PropertyPublishedListener {
    private final User buyer;
    private final Filter matcher;

    public SavedSearchAlertListener(User buyer, Filter matcher) {
        this.buyer = buyer;
        this.matcher = matcher;
    }

    @Override
    public void onPropertyPublished(Property property) {
        if (matcher.isSatisfiedBy(property)) {
            System.out.println(buyer.getName() + " ALERT: new listing matches your saved search — " + property);
        }
    }
}

14. Dynamic pricing — the Decorator pattern, and the contrast with Proxy that's worth stating out loud

public interface PriceCalculator {
    double calculate(Property property);
}

public class BasePriceCalculator implements PriceCalculator {
    @Override
    public double calculate(Property property) { return property.getPrice(); }
}
public abstract class PriceCalculatorDecorator implements PriceCalculator {
    protected final PriceCalculator delegate;

    protected PriceCalculatorDecorator(PriceCalculator delegate) {
        this.delegate = delegate;
    }
}
public class SeasonalSurchargeDecorator extends PriceCalculatorDecorator {
    private final double flatSurcharge;

    public SeasonalSurchargeDecorator(PriceCalculator delegate, double flatSurcharge) {
        super(delegate);
        this.flatSurcharge = flatSurcharge;
    }

    @Override
    public double calculate(Property property) {
        return delegate.calculate(property) + flatSurcharge;
    }
}

public class LoyaltyDiscountDecorator extends PriceCalculatorDecorator {
    private final double discountRate;
    // constructor mirrors SeasonalSurchargeDecorator's

    @Override
    public double calculate(Property property) {
        double amount = delegate.calculate(property);
        return amount - (amount * discountRate);
    }
}

public class TaxDecorator extends PriceCalculatorDecorator {
    private final double taxRate;
    // constructor mirrors the others

    @Override
    public double calculate(Property property) {
        double amount = delegate.calculate(property);
        return amount + (amount * taxRate);
    }
}
PriceCalculator fullQuote = new TaxDecorator(
        new LoyaltyDiscountDecorator(
                new SeasonalSurchargeDecorator(basePrice, flatSurcharge), loyaltyDiscountRate),
        taxRate);

15. Listing validation — Chain of Responsibility, and a bug the verification step actually caught

public abstract class ListingValidationHandler {
    private ListingValidationHandler next;

    public ListingValidationHandler setNext(ListingValidationHandler next) {
        this.next = next;
        return next;
    }

    public final void handle(Property property) {
        validate(property);
        if (next != null) {
            next.handle(property);
        }
    }

    protected abstract void validate(Property property);
}
public class PriceSanityHandler extends ListingValidationHandler {
    @Override
    protected void validate(Property property) {
        if (property.getPrice() <= 0) { throw new ListingValidationException(/* ... */); }
        if (property.getPrice() > MAX_REASONABLE_PRICE) { throw new ListingValidationException(/* ... */); }
    }
}

public class RequiredFieldsHandler extends ListingValidationHandler {
    @Override
    protected void validate(Property property) {
        // id, bhk, propertyType, furnishing, location must all be present
    }
}

public class DuplicateListingHandler extends ListingValidationHandler {
    private final ListingRegistry listingRegistry;
    // scans every OTHER registered listing for a location/price/bhk match
}
private static ListingValidationHandler buildValidationChain(ListingRegistry listingRegistry) {
    ListingValidationHandler priceCheck = new PriceSanityHandler();
    priceCheck.setNext(new RequiredFieldsHandler()).setNext(new DuplicateListingHandler(listingRegistry));
    return priceCheck;
}
if (other.getId().equals(property.getId()) || other.getLocation() == null) {
    continue;
}

16. Paying for a purchase — the Adapter pattern, for a mismatch this codebase doesn't control

public interface PaymentGateway {
    PaymentResult charge(User payer, double amountInRupees);
}
// FastPay: paise instead of rupees, and its own response type
public class FastPaySdk {
    public FastPayResponse makePayment(String customerId, long amountInPaise) { /* ... */ }
}
public class FastPayResponse {
    public final boolean approved;
    public final String referenceCode;
    public final String errorCode;
}

// LegacyBank: a status code instead of an exception/boolean, and the transaction id has to be
// fetched with a SEPARATE call afterward instead of being returned directly
public class LegacyBankGatewaySdk {
    public int submitTransaction(String accountHolderName, double amountInRupees) { /* returns 0 or an error code */ }
    public String getLastTransactionId() { /* only meaningful right after a submitTransaction() that returned 0 */ }
}
public class FastPayAdapter implements PaymentGateway {
    private final FastPaySdk sdk;

    @Override
    public PaymentResult charge(User payer, double amountInRupees) {
        long amountInPaise = Math.round(amountInRupees * 100);
        FastPayResponse response = sdk.makePayment(payer.getId(), amountInPaise);
        return response.approved
                ? PaymentResult.success(response.referenceCode)
                : PaymentResult.failure("FastPay declined: " + response.errorCode);
    }
}

public class LegacyBankGatewayAdapter implements PaymentGateway {
    private final LegacyBankGatewaySdk sdk;

    @Override
    public PaymentResult charge(User payer, double amountInRupees) {
        int statusCode = sdk.submitTransaction(payer.getName(), amountInRupees);
        return statusCode != 0
                ? PaymentResult.failure("LegacyBank error code " + statusCode)
                : PaymentResult.success(sdk.getLastTransactionId());
    }
}
PaymentResult result = paymentGateway.charge(actor, listing.getProperty().getPrice());
if (!result.isSuccess()) {
    throw new PaymentFailedException(result.getFailureReason());
}
listing.confirmPurchase();

17. Undoable listing actions — the Command pattern, and the honesty of an undo() that sometimes can't

public interface ListingCommand {
    void execute();
    void undo();
}
public class BuyPropertyCommand implements ListingCommand {
    private final PropertyService service;
    private final String propertyId;
    private final User actor;

    @Override
    public void execute() { service.buyProperty(propertyId, actor); }

    @Override
    public void undo() { service.cancelPurchase(propertyId, actor); }
}

public class CancelPurchaseCommand implements ListingCommand {
    // ... same fields ...
    @Override
    public void execute() { service.cancelPurchase(propertyId, actor); }

    @Override
    public void undo() { service.buyProperty(propertyId, actor); }
}
public class PublishListingCommand implements ListingCommand {
    // ...
    @Override
    public void undo() {
        throw new CommandNotUndoableException(
                "Cannot un-publish " + propertyId + " -- no AVAILABLE -> DRAFT transition exists");
    }
}

ConfirmPurchaseCommand.undo() throws the same way, citing the missing refund flow instead of the missing state transition.

public class CommandHistory {
    private final Deque<ListingCommand> history = new ArrayDeque<>();

    public void execute(ListingCommand command) {
        command.execute();
        history.push(command);
    }

    public void undoLast() {
        if (history.isEmpty()) {
            throw new IllegalStateException("Nothing to undo");
        }
        ListingCommand last = history.peek();
        last.undo();
        history.pop();
    }
}

18. Domestic vs NRI market bundles — the Abstract Factory pattern, closing the loop on three existing products

public interface MarketServiceFactory {
    PaymentGateway createPaymentGateway();
    ListingValidationHandler createValidationChain(ListingRegistry listingRegistry);
    PriceCalculator createPriceCalculator();
}
public class DomesticMarketFactory implements MarketServiceFactory {
    private static final double DOMESTIC_PRICE_CAP = 100_000_000; // 10 crore — same as before this step
    private static final double DOMESTIC_TAX_RATE = 0.05;

    @Override
    public PaymentGateway createPaymentGateway() {
        return new FastPayAdapter(new FastPaySdk());
    }

    @Override
    public ListingValidationHandler createValidationChain(ListingRegistry listingRegistry) {
        ListingValidationHandler priceCheck = new PriceSanityHandler(DOMESTIC_PRICE_CAP);
        priceCheck.setNext(new RequiredFieldsHandler()).setNext(new DuplicateListingHandler(listingRegistry));
        return priceCheck;
    }

    @Override
    public PriceCalculator createPriceCalculator() {
        return new TaxDecorator(new BasePriceCalculator(), DOMESTIC_TAX_RATE);
    }
}

public class NriMarketFactory implements MarketServiceFactory {
    private static final double NRI_PRICE_CAP = 500_000_000; // 50 crore
    private static final double NRI_TAX_RATE = 0.12;

    @Override
    public PaymentGateway createPaymentGateway() {
        return new LegacyBankGatewayAdapter(new LegacyBankGatewaySdk());
    }

    @Override
    public ListingValidationHandler createValidationChain(ListingRegistry listingRegistry) {
        ListingValidationHandler priceCheck = new PriceSanityHandler(NRI_PRICE_CAP);
        priceCheck.setNext(new RequiredFieldsHandler()).setNext(new DuplicateListingHandler(listingRegistry));
        return priceCheck;
    }

    @Override
    public PriceCalculator createPriceCalculator() {
        return new TaxDecorator(new BasePriceCalculator(), NRI_TAX_RATE);
    }
}
public DefaultPropertyService(BookingPlatform platform, ListingRegistry listingRegistry,
        PropertyAlertService alertService, MarketServiceFactory marketFactory) {
    this.platform = platform;
    this.listingRegistry = listingRegistry;
    this.alertService = alertService;
    this.validationChain = marketFactory.createValidationChain(listingRegistry);
    this.paymentGateway = marketFactory.createPaymentGateway();
    this.priceCalculator = marketFactory.createPriceCalculator();
}
@Override
public double quotePrice(String propertyId, User actor) {
    Property property = listingRegistry.get(propertyId).getProperty();
    return priceCalculator.calculate(property);
}

19. Replacing the main() walkthrough with a real JUnit test suite

Example(s) Test class Pattern
1–3 filter.FilterEngineTest Strategy + Composite + Builder (steps 1–10)
4 security.RoleBasedAccessTest Protection Proxy (step 11)
5 listing.ListingLifecycleTest State (step 12)
6 alert.PropertyAlertServiceTest Observer (step 13)
7 pricing.PriceCalculatorDecoratorTest Decorator (step 14)
8 validation.ListingValidationChainTest Chain of Responsibility (step 15)
9 payment.PaymentGatewayAdapterTest Adapter (step 16)
10 command.ListingCommandTest Command (step 17)
11 market.MarketServiceFactoryTest Abstract Factory (step 18)

20. A smoke test alongside the unit tests — trading isolation for a real end-to-end pass

Step 19 replaced main()'s eleven examples with nine granular JUnit test classes, one per pattern area, each building its own fresh fixtures in @BeforeEach. That's the right default for unit tests: each test is independent, order doesn't matter, and a failure points at exactly one thing. But it also throws away something the old main() had almost by accident — a single continuous run through every pattern in sequence, sharing one BookingPlatform, one ListingRegistry, one seller and buyer, the way the system actually gets used. Isolated tests can't catch a bug that only shows up when step 6's alert side effect leaks into step 7's pricing call, or when a listing published in step 5 is still sitting in AVAILABLE when step 8's validation chain runs against it. That class of bug is exactly what a smoke test is for, and it's what the user asked for explicitly: "smoke tests — similar to what we were earlier doing in the main class."

The mechanism is three JUnit annotations working together, none of which the granular tests needed:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class SmokeTest {
    private BookingPlatform platform;
    private FilterFactory filterFactory;
    private ListingRegistry listingRegistry;
    private PropertyAlertService alertService;
    private PropertyService propertyService;
    private User seller;
    private User buyer;

    @Test @Order(1) void step01_inventoryAndAndQuery() { ... }
    @Test @Order(2) void step02_orOfGroupsQuery() { ... }
    ...
    @Test @Order(11) void step11_abstractFactoryMarkets() { ... }
}

@TestInstance(PER_CLASS) tells JUnit to build the test class exactly once and reuse that one instance for every @Test method, instead of the normal per-method fresh instance — that's what lets platform, listingRegistry, and the rest survive from one step to the next as ordinary instance fields, set once in step01 and read by every step after it. @TestMethodOrder(MethodOrderer.OrderAnnotation.class) turns off JUnit's default (deliberately unspecified) method ordering and sorts by the @Order(n) value on each method instead, so the eleven steps run in exactly the sequence the old main() used — which matters here specifically because later steps depend on state earlier steps built.

The eleven steps mirror the original main()'s Examples 1–11 one for one: flat AND query and inventory count, OR-of-groups, NOT, role-based access (posting as seller vs. buyer, then switching roles), the full listing lifecycle (draft → publish → buy → confirm → sold, plus a cancel-and-return-to-available path), the Observer-based saved-search alert (subscribe, publish a match, publish a non-match, unsubscribe), the Decorator-based dynamic price quote, the Chain-of-Responsibility validation chain (bad price, missing location, duplicate, then a clean pass), the Adapter-based payment gateways (a successful charge reaching SOLD, a failed charge blocking it), the Command pattern's undo (a genuine inverse undo, then a non-undoable command correctly refusing), and finally the Abstract Factory market bundles (a price the domestic cap rejects but the NRI cap accepts, quoted at each market's own tax rate). Each step is still a real, independently reportable @Test method with its own assertions — a failure in step 7 is reported as SmokeTest#step07_dynamicPricingDecorator, not as a crash halfway through a giant method — the difference from the granular tests is only that the fixtures are shared and the order is enforced, not that the assertions are any less specific.

This is intentionally additive, not a replacement for step 19's suite. The granular tests stay the primary line of defense — fast, isolated, order-independent, and precise about what broke. SmokeTest is the one place a regression that only appears under shared state, or only appears when steps run in the platform's natural sequence, has anywhere to surface. Cascading failure is a feature here: if step04_roleBasedAccess leaves the buyer in the wrong role, every later step that depends on that role assignment failing loudly is more useful than silently passing.

Verification followed the same offline path as step 19, for the same reason — this cloud sandbox's network allowlist doesn't include Maven Central, so mvn test can't resolve JUnit 5 here. The hand-built JUnit-5-API stand-in from step 19 needed two small extensions to support this file: @TestInstance/TestInstance.Lifecycle, @TestMethodOrder, @Order, and a MethodOrderer.OrderAnnotation marker type, plus reflection-runner logic to build one shared instance when PER_CLASS is present and to sort @Test methods by @Order when OrderAnnotation is requested. With those in place, the actual SmokeTest.java source — not a rewritten stand-in — compiled and ran cleanly alongside the nine step-19 test classes: 47 tests total (36 granular + 11 smoke steps), all passing, in the correct order, including the expected alert println firing mid-sequence during step 6. That's a solid proxy but, as with step 19, not a substitute for running mvn test for real in an environment with Maven Central access — worth doing once to confirm the real JUnit 5 engine agrees.