Une histoire d'objets obèses

/img/post/obesite.jpg

Il arrive dans un projet en Java de se trouver, selon le métier ou le domaine adressé, avec des classes d’objets obèses en méthodes, qu’elles soient publics ou propres aux objets de la classe. Or, sachant que l’on passe plus de temps à lire, voir à toucher du code existant qu’à en écrire de nouveaux, ceci peut vite devenir pénible. Evidemment, avec nos IDE actuels, il est facile de naviguer entre les différentes méthodes et propriétés d’une classe. Mais en général, ceci signifie que l’on sait, déjà, à peu près ce que l’on cherche ou que l’on connait a minima les responsabilités ou certaines particularités d’implémentation de la classe. Lorsqu’on doit toucher du code inconnu ou au mieux revenir sur du code au bout de 6 mois, nous aimons bien identifier aisément les parties à utiliser ou à retoucher et accéder à l’essentiel sans se perdre dans les méandres de la ou des classes inspectées. En effet, il peut être difficile, avec de telles classes, de démêler le comportement de l’objet, ce qui le caractérise, du reste. En tout cas c’est mon cas. Pour éviter de tels embonpoints, je vous propose d’utiliser les approches de certains langages fonctionnels comme Haskell (ou OCaml), dans lesquels les types et les fonctions sur ces types sont séparés (au sein d’un même module tout de même).

Le problème de l’obésité de classes métier en Java (mais aussi dans d’autres langages impératifs) est fréquent. Par exemple, sur un moteur de gestion des événements sur un agenda, nous sommes arrivés, avec un collègue, à une classe CalendarEvent de plus de 1000 lignes de code. Une solution serait de découper les responsabilités de ces objets en sous-responsabilités dans d’autres classes d’objets qui composeraient in fine la classe d’objet finale. Une autre solution serait de découper la classe, dans notre cas, CalendarEvent, en deux parties, une classe qui rassemblerait les propriétés et les accesseurs de notre modèle et une interface qui regrouperait le comportement métier (avec leur implémentation par défaut, merci Java 8).

public class CalendarEvent implements CalendarEventBehaviour, Cloneable {

  private OffsetDateTime startDateTime;
  private OffsetDateTime endDateTime;
  private boolean allDay;
  private String title;
  private String description;
  private String location;
  private Calendar calendar;

  public static CalendarEvent on(final Period period) {
    return new CalendarEvent(period.getStartDateTime(), period.getEndDateTime());
  }

  private CalendarEvent(final OffsetDateTime startDateTime, final OffsetDateTime endDateTime) {
    this.startDateTime = startDateTime.withOffsetSameInstant(ZoneOffset.UTC);
    this.endDateTime = endDateTime.withOffsetSameInstant(ZoneOffset.UTC);
    this.allDay = this.startDateTime.isEqual(
        this.startDateTime.toLocalDate().atStartOfDay().atOffset(ZoneOffset.UTC)) &&
        this.endDateTime.isEqual(this.endDateTime.toLocalDate()
            .atStartOfDay()
            .atOffset(ZoneOffset.UTC));
  }
  
  ...
}

interface CalendarEventBehaviour {

  CalendarEvent self();

  default CalendarEvent planOn(final Calendar calendar) {
    return Transaction.perform(() ->
      CalendarEventRepository.get().put(self().setCalendar(calendar))
    );
  }

  ...
}

L’interface CalendarEventBehaviour, qui définit l’ensemble du comportement de CalendarEvent, n’est visible qu’au niveau du package Java qui comprend aussi notre classe. L’utilisation ici d’une interface en lieu et place d’une classe permet à CalendarEvent de pouvoir faire partie d’un arbre d’héritage (Java ne supportant que l’héritage simple). Vous remarquerez la définition de la méthode self() qui permet de retrouver l’instance sur laquelle le comportement s’applique ; oui, ça ressemble fortement à une simulation pauvre d’un mixin, technique bien connue des rubyistes.

Or, si cette solution marche, elle n’est pas, à mon avis, satisfaisante. En effet, il est important, dans une conception orientée objet, de définir la représentation d’un modèle métier par l’ensemble de son comportement et que celui-ci soit exposé. Or, ici, le comportement est caché de l’extérieur. On pourrait alors inverser : CalendarEvent rassemblerait le comportement de l’objet et la structure de la classe serait déportée dans une autre classe. Par exemple :

public interface CalendarEvent {

  CalendarEventState self();

  static CalendarEvent on(final Period period) {
    return new CalendarEventStructure(period.getStartDateTime(), period.getEndDateTime());
  }

  CalendarEvent withTitle(String aTitle);

  CalendarEvent withDescription(String aTitle);
  
  CalendarEvent inLocation(String aLocation);

  default CalendarEvent planOn(final Calendar calendar) {
    return Transaction.perform(() ->
      CalendarEventRepository.get().put(self().setCalendar(calendar))
    );
  }
  
  ...
}

class CalendarEventState implements CalendarEvent, Cloneable {

  private OffsetDateTime startDateTime;
  private OffsetDateTime endDateTime;
  private boolean allDay;
  private String title;
  private String description;
  private String location;
  private Calendar calendar;

  CalendarEventState(final OffsetDateTime startDateTime, final OffsetDateTime endDateTime) {
    this.startDateTime = startDateTime.withOffsetSameInstant(ZoneOffset.UTC);
    this.endDateTime = endDateTime.withOffsetSameInstant(ZoneOffset.UTC);
    this.allDay = this.startDateTime.isEqual(
        this.startDateTime.toLocalDate().atStartOfDay().atOffset(ZoneOffset.UTC)) &&
        this.endDateTime.isEqual(this.endDateTime.toLocalDate()
            .atStartOfDay()
            .atOffset(ZoneOffset.UTC));
  }

  public CalendarEvent withTitle(String aTitle) {
    this.title = aTitle;
    return this;
  }

  ...
}

Déjà, un détail me gêne dans cette approche. En l’occurrence, la présence de la méthode technique self() dans CalendarEvent alors qu’elle ne devrait pas. A moins que l’on préfère exposer les méthodes utilisées dans les implémentations par défaut afin de pouvoir les utiliser en lieu et place de cette méthode self(). Ce qui, finalement, revient au même, voir en pire.

Pour corriger ce problème de l’exposition d’opérations techniques dans l’interface, on pourrait très bien imaginer de découper notre classe en trois parties dans un même package Java : une première qui définirait notre objet métier en exposant son comportement, une autre qui implémenterait ce comportement, juste le comportement, avec les fonctions techniques nécessaires à cette implémentation, mais pas les accesseurs, et enfin une troisième qui lui donnerait une structure dotée de ses accesseurs.

D’abord l’interface qui définit l’objet métier :

public interface CalendarEvent {

  static CalendarEvent on(final Period period) {
    return new CalendarEventStructure(period.getStartDateTime(), period.getEndDateTime());
  }

  CalendarEvent planOn(final Calendar calendar);

  CalendarEvent withTitle(String aTitle);

  CalendarEvent withDescription(String aDescription);

  ...
}

Ensuite l’implémentation juste du comportement. Celui-ci peut-être réalisé sous la forme d’une interface avec des méthodes par défaut quand c’est possible ou alors, ce qui est fréquent, sous forme d’une classe abstraite :

interface CalendarEventBehaviour extends CalendarEvent {

  CalendarEvent setCalendar(final Calendar calendar);

  @Override
  default CalendarEvent planOn(final Calendar calendar) {
    return Transaction.perform(() ->
        CalendarEventRepository.get().put(this.setCalendar(calendar))
    );
  }

  @Override
  default CalendarEvent update() {
    return Transaction.perform(() ->
        CalendarEventRepository.get().put(this)
    );

  ...
}

Et enfin la dernière classe qui définit finalement la structure même de l’objet métier :

class CalendarEventState implements CalendarEventBehaviour, Cloneable {

  private OffsetDateTime startDateTime;
  private OffsetDateTime endDateTime;
  private boolean allDay;
  private String title;
  private String description;
  private String location;
  private Calendar calendar;

  CalendarEventState(final OffsetDateTime startDateTime, final OffsetDateTime endDateTime) {
    this.startDateTime = startDateTime.withOffsetSameInstant(ZoneOffset.UTC);
    this.endDateTime = endDateTime.withOffsetSameInstant(ZoneOffset.UTC);
    this.allDay = this.startDateTime.isEqual(
        this.startDateTime.toLocalDate().atStartOfDay().atOffset(ZoneOffset.UTC)) &&
        this.endDateTime.isEqual(this.endDateTime.toLocalDate()
            .atStartOfDay()
            .atOffset(ZoneOffset.UTC));
  }
  
  @Override
  public CalendarEventState withTitle(String aTitle) {
    this.title = aTitle;
    return this;
  }

  ...
}

Vous aurez remarqué que l’interface métier expose une méthode statique (méthode de classe) pour la construction des objets ou, plus exactement, l’instanciation de la classe concrète. Il y a là un couplage entre l’interface et son implémentation qui fera dire à certains que c’est mal. Pour couper ce cycle entre les deux, il suffit de remplacer l’instanciation directe par l’utilisation d’une fabrique :

class CalendarEventFactory {

  public CalendarEvent makeEventOn(final Period period) {
    return new CalendarEventState(period.getStartDateTime(), period.getEndDateTime());
  }
}

public interface CalendarEvent {

  static CalendarEvent on(final Period period) {
    return new CalendarEventFactory().makeEventOn(period);
  }
  
  ...
}

Avec cette approche, notre classe d’objet obèse initiale est découpée en trois parties distinctes, chacune avec un rôle bien défini, ce qui permet d’avoir des classes de tailles plus correctes et de pouvoir naviguer directement dans celle(s) qui nous intéresse(nt) sans s’y perdre.


comments powered by Disqus