Skip to content

Instantly share code, notes, and snippets.

@JordanRickman
Last active April 17, 2023 01:35
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save JordanRickman/672d11dc7f306a56ba75dcbaf0522bbe to your computer and use it in GitHub Desktop.
Save JordanRickman/672d11dc7f306a56ba75dcbaf0522bbe to your computer and use it in GitHub Desktop.
The Connector OOP Pattern

Connectors: A Design Pattern for Maintaining Consistent Object Relationships

The entities in an application's domain model are defined in large part by their relationships between them. The basic three are one-to-one, one-to-many, and many-to-many, but additional invariants may be layered on top of these. In a traditional OOP style, entities are responsible for maintaining these invariants, via logic in their public mutator methods. I propose an alternative that I call the connector pattern. In the connector pattern, object relationships are created and destroyed (connected and disconnected) by a third object called a Connector.

Why Connectors?

While objects should be responsible for maintaining their own local invariants, relationships are not local. They encompass both objects involved in a relationship. As a result, mutator methods on both objects must enforce a shared invariant. If a Foo has many Bars, then Foo.addBar(Bar) and Bar.setFoo(Foo) should be equivalent operations. These two methods must operate consistently with each other, even though they live in different classes and have access to different information. When there are more complex constraints than one-vs-many, this can be tricky.

It is much easier to maintain invariants if all mutations pass through a single place in code. Connectors provide that single place. The Connector is given responsibility for keeping a relationship consistent, whatever that might involve. All mutations should pass through the connector - client code calls Connector.connect(Foo, Bar) or Connector.disconnect(Foo, Bar), rather than calling the mutators on Foo and Bar directly.

With graphs of 3 or more inter-dependent objects, consistency is even harder to maintain, and having a single object that's responsible is even more of a headache-reducer. These n-dimensional connectors I call assemblers, and can be composed of connectors. I want to develop that concept more later (and will link to the results once I do), but for this document I just want to introduce the connectors concept. Let's dive into examples, starting with the three classic relationships: one-to-one, one-to-many, and many-to-many.

Demonstration

Foo <-1:1-> Bar

Mutator Methods

class Foo {
  private Bar bar;

  public void setBar(Bar bar) {
    this.bar = bar;
    if ( !bar.getFoo().equals(this) ) { // avoid infinite recursion
      bar.setFoo(this);
    }
  }
}

class Bar {
  private Foo foo;

  public void setFoo(Foo foo) {
    this.foo = foo;
    if ( !foo.getBar().equals(this) ) { // avoid infinite recursion
      foo.setBar(this);
    }
  }
}

Connector Pattern

class FooBarConnector implements OneToOneConnector<Foo,Bar> {
  public void connect(Foo foo, Bar bar) {
    // No special logic in mutators; Foo and Bar are POJOs.
    foo.setBar(bar);
    bar.setFoo(foo);
  }

  public void disconnect(Foo foo, Bar bar) {
    foo.setBar(null);
    bar.setFoo(null);
  }
}

Foo <-1:n-> Bar

Mutator Methods

class Bar {
  private Foo foo;

  public void setFoo(Foo foo) {
    this.foo = foo;
    // Avoids infinite loops. But what if we want to allow repeats?
    if (foo != null && !foo.getBars().contains(this)) {
      foo.addBar(this);
    }
  }
}

class Foo {
  private Collection<Bar> bars;

  public void addBar(Bar bar) {
    bars.add(bar);
    bar.setFoo(this);
  }

  public void removeBar(Bar bar) {
    bars.remove(bar);
    if (bar.getFoo().equals(this)) { // In case our caller already moved bar to another Foo
      bar.setFoo(null);
    }
  }

  public void clearBars() {
    bars.stream().forEach(bar -> this.removeBar(bar));
  }
}

Connector Pattern

Let Foo and Bar be POJOs. Then...

class FooBarConnector implements OneToManyConnector<Foo,Bar> {
  public void connect(Foo foo, Bar bar) {
    // Foo is still responsible for initializing its bars to an empty Collection
    //  (in order to avoid NullPointerException) - that is a local invariant,
    //  not a relational one.
    foo.getBars().add(bar);
    bar.setFoo(foo);
  }

  public void disconnect(Foo foo, Bar bar) {
    foo.getBars().remove(bar);
    bar.setFoo(null);
  }

  public void move(Bar bar, Foo from, Foo to) {
    disconnect(from, bar);
    connect(to, bar);
  }
}

Foo <-n:n-> Bar

Let's start with the connector version this time.

Connector Pattern

class FooBarConnector implements ManyToManyConnector<Foo,Bar> {
  public void connect(Foo foo, Bar bar) {
    foo.getBars().add(bar);
    bar.getFoos().add(foo);
  }

  public void disconnect(Foo foo, Bar bar) {
    foo.getBars().remove(bar);
    bar.getFoos().remove(foo);
  }
}

Mutator Methods

Like the one-to-one relationship, the code isn't too tricky, but you have to repeat yourself across both classes.

class Foo {
  private Set<Bar> bars;

  public void addBar(Bar bar) {
    bars.add(bar);
    if (!bar.getFoos().contains(this)) {
      bar.addFoo(this);
    }
  }

  public void removeBar(Bar bar) {
    bars.remove(bar);
    if (bar.getFoos().contains(foo)) {
      bar.removeFoo(this);
    }
  }
}

// And the Bar class looks exactly the same, but with the foos and bars interchanged.

Notice also that performance could be worse in the mutator version. For consistency, we want both foo.addBar(bar) and bar.addFoo(foo) to create the connection in both directions. That is, they should be equivalent operations - as should the twin remove operations. However, we need to avoid infinite loops by checking the contents of the other object before calling its mutator.

Thus, foo.removeBar(bar) calls bar.getFoos().contains(foo), iterating through potentially all the Foos in bar. When it finds foo, it calls bar.removeFoo(foo). That calls foo.getBars().contains(bar), which definitely iterates through all the Bars in foo, since our bar will no longer be found there. So on top of the necessary iterations of foos.remove(bar) and bars.remove(foo), we have two extra Big-O(n) operations.

Obviously, in Java, you can and should use HashSet to make contains() and remove() faster. But in other languages, such as JavaScript, you may not have much control over how a collection is implemented.

Using Connectors to Enforce Relational Invariants

Often, simply saying that "a Foo has many Bars" is not a complete description of an entity relationship. In particular, there may be relational invariants that you want to maintain. A relational invariants is any constraint that involves information from both objects in the relationship. Here is where the connector pattern really begins to shine. By providing a relational view instead of a local view, Connectors provide a single place in code to enforce invariants that may be difficult or even impossible to enforce locally. Let's work an example.

One-to-Many With Uniqueness

One common example of a relational invariant is set uniqueness. Suppose that our application handles Messages, perhaps to be sent to customers via a mailing list. Each Message has many Translations, and each Translation has a LangLocale - uniquely identified by an ISO code such as "en_US". Our relational uniqueness invariant is that each Message can have at most one Translation for each LangLocale.

A Java developer's first instinct might be to make equals() and hashCode() on Translation compare the message and the langlocale fields, then use a HashSet in Message's collection of Translations. But this strategy severely limits our options. An add on a Set will simply fail in the case of a collision, silently apart from a false return value. Perhaps we want another behavior, like replacing the previous Translation. (Yes, I know that this could be more easily modeled with a Map relationship, but those aren't always practical in a domain model, especially when backed by SQL). Moreover, using equals/hashCode locks us into a particular definition of Translation equality, and there may be other places in our codebase where we want a different uniqueness criteria for our Sets.

More importantly, this strategy doesn't allow us to maintain a consistent model using local mutators. Let's try to do that, with a strategy that throws an exception if we violated uniqueness.

enum LangLocale {
  EN_US("en_US"),
  EN_GB("en_GB"),
  //... and so on
}

class Message {
  private Set<Translation> translations = new HashSet<Translation>();
  
  public void addTranslation(Translation translation) {
    if (!translations.add(translation)) {
      throw new ConstraintViolationException("This Message already has a Translation for "
           + translation.getLangLocale().getCode());
    }
    translation.setMessage(this);
  }
}

Do you see the problem? The if (!translations.add(translation)) won't enforce uniqueness correctly. If we define equals()/hashcode() to include Translation.getMessage(), then on that first line when we try to add it to the Set, its message may not match this - so the Set may treat it as unique and accept the add even if there's a lang-locale collision. We can't put translation.setMessage(message) on the first line by itself - we need mutual recursion to make our two mutators equivalent, so we need to prevent infinite recursion. Let's try that.

class Message {
  private Set<Translation> translations = new HashSet<Translation>();
  
  public void addTranslation(Translation translation) {
    if (!translations.contains(translation)) {
      translation.setMessage(message);
      if (!translations.add(translation)) {
        throw new ConstraintViolationException("This Message already has a Translation for "
           + translation.getLangLocale().getCode());
      }
    }
  }
}

Okay, now what does the Translation side of the mutual recursion look like?

class Translation {
  private Message message;
  private LangLocale langLocale;
  private String contents;
  
  public void setMessage(Message message) {
    this.message = message;
    if (!message.getTranslations().contains(message)) {
      message.addTranslation(translation);
    }
  }
  
  // equals/hashCode comparing ONLY message and langLocale
}

Easy enough. But wait! After this.message = message, the contains() will report true if there's another Translation with the same LangLocale. Because it uses the same equals/hashCode that we are using to enforce our constraint. So we'll never recurse back to message.addTranslation(translation), and the Message's Set will never be updated.

In this pattern of mutually recursive local mutators, we have to use contains() to avoid infinite recursion, so we can't also use it for enforcing uniqueness. But a similar problem arises if you try checking uniqueness yourself, instead of letting equals/hashCode/HashSet do it for you.

Discussion

I hope that in the examples above, you also find that Connectors are more cleanly-written, and easier to get right, than are symmetric mutators recursively calling each other. That's the first advantage of Connectors. However, by providing a single place in code where a given relationship can be modified, the connector pattern can solve other problems as well. If your model is shared between threads, you can synchronize within the connector methods, since they are the single point where change happens. If you are working with a persistence layer and want atomic operations, you can make your connector methods transactional, ensuring that the two sides of a relationship will not get out-of-sync. If you want an audit log, publish change events from your connector, and a relationship change will appear as a single event rather than two events.

The core of the connector pattern is to flip perspective from the local viewpoint of an object in a relationship, to the shared viewpoint of the relationship itself. When that relationship is reified in the form of a Connector object responsible for all changes to the relationship, all sorts of things become cleaner and easier to implement.

@hellerim
Copy link

Hi Jordan,
the pattern you describe is useful - usually, you get something like this for free when you're using an object-relational mapper. I'm a little bit unhappy with the name you chose for it. This pattern clearly implements referential integrity constraints (foreign keys and associations) but in this context, I would consider the foreign key relationship itself a connector but not the constraint. Would you mind chosing a different name? I did not come up with a better name yet, may be you got an idea? ("detail pattern", "master-detail pattern", or simply "relationship" or "foreign key" and "association"?)
I think the name "connector pattern" is much more appropriate for a mechanism which connects two software components which communicate wtih each other, e.g. via pairs of command events - data sinks and conversely: a connector would install a command handler on component A for event "command c issued" which sends the command and its data to a corresponding data sink on component B, i.e . it calls an appropriate method on component B, and conversely (optional), just as a plumber would do. This is an architectural pattern which allows for flexible wiring of components. I'm just elaborating on this.

Kind regards

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment