I have some problems regarding best-practice design between data source layer(Entity), domain layer(Service) and presentation layer (controller):
I have one entity with a type field in the database to distinguish between two subtypes(Burger and Pizza), because they share most attributes and act similarly Let's say, the only difference is, Burger has attribute "layer number", Pizza has an attribute "Diameter" and their cook() methods have different implementations.
So my Idea is: I use single big table that accomodates both types, intentionally leaving some unused fields empty.
Example of Table RECIPE:
id | name | type | diameter | layerNumber | status |
---|---|---|---|---|---|
1 | VegiBurger | BURGER | null | 1 | DONE |
2 | ChickenBurger | BURGER | null | 3 | WAIT |
3 | Pizza Mozzarella | PIZZA | 10 | null | DONE |
Now comes the problem: I’ve moved the cook() logic into a wrapper class, RecipeCooker, because business logic should not be in Entity class. However, this means I now have two versions of cook() for both Recipe types, depending on the type field.
My Question: Is this a bad pattern? How would you improve this design that follows SOLID principle/dev best practices?
This is my data source layer: (entities should not contain business logics)
@Setter
@Getter
@Entity
public class Recipe {
// properties for the recipe
private String id;
private RecipeType type; // Can be BURGER or PIZZA
private RecipeStatus status;
// Other fields specific to a Recipe
}
This is my Controller (presentation layer)
// POST methods to create a recipe and cook it
@PostMapping("/")
public Recipe createRecipe(@RequestBody @Valid Recipe recipe) {
return recipeService.createRecipe(recipe);
}
@PostMapping("/{recipeId}/cook")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void cookRecipe(@PathVariable String recipeId) {
recipeService.cookRecipe(recipeId);
}
//Business logics are here, but this method cook() is now tightly coupled with two implementations of Recipe subtypes
public class RecipeCooker {
private final Recipe recipe;
private final RecipeRepository recipeRepository;
public RecipeCooker(Recipe recipe,
RecipeRepository recipeRepository) {
this.recipe = recipe;
this.recipeRepository = recipeRepository;
}
//THIS IS PROBABLY BAD DESIGN, BUT I HAVE NO IDEA HOW TO IMPROVE.
@Override
public String cook() throws Exception {
switch (recipe.getType()) {
case BURGER:
return cookBurger();
case PIZZA:
return cookPizza();
default:
throw new RecipeTypeNotExistException("Invalid recipe type: " + recipe.getType());
}
}
// Method for cooking Burger
private String cookBurger(){...}
// Method for cooking Pizza
private String cookPizza(){...}
}
2 Answers 2
I see problems in data and business layers:
Data Layer
The design of the DB table is not normalized. If in future more recipes are added with specific attributes, you would have to add more nullable columns to your table. there are two possible solutions:
Table
RECIPE
should have only the common attributes for all recipes. Create additional tables to hold recipe-specific attributes, with foreign key to point toRECIPE
. for example, tableBURGER_RECIPE
:id recipe_id layerNumber 1 1 1 2 2 3 You can fetch the recipe for vegi burger using
join
in the query.A more general approach would be to create a key-value table to hold all recipe-specific attributes:
id recipe_id att_name att_value 1 1 layerNumber 1 2 2 layerNumber 3 3 3 diameter 10 In this case, the Java entity for the attributes would probably be a
Map<String, Integer>
or perhapsMap<String, Object>
but it will still hold attributes for requested recipe because of join condition.
Service Layer
cook()
method with switch on recipe type is bad design. This calls for a polymorphic solution:
Identify a common signature for cooking methods and put in an interface. perhaps something like
public interface Cooker { String cook(Recipe recipeEntity, Map<String, Integer> attributes); }
(however, I would expect the data layer to combine
recipeEntity
and attributes into one DTO, since the separation is a DB implementation detail). The interface can also be an abstract superclass that holds common processing for all recipes (error handling?)Create required implementations for the interface per recipe:
public class BurgerCooker implements Cooker { @Override public String cook(Recipe recipeEntity, Map<String, Integer> attributes) { // ... } }
Now you just need to associate recipe type with the relevant cooker implementation. I suggest to add a property to the
RecipeType
enum:@Getter public enum RecipeType { BURGER(new BurgerCooker()) , private Cooker cooker; RecipeType(Cooker cooker) { this.cooker = cooker; } }
This gives direct access to cooking method from recipe type:
recipe.getType().getCooker().cook(...)
.
The only problem with this solution is that the cooker implementations are not instantiated using Spring injection mechanism. If indeed you need to autowire cooker implementations (perhaps the cookers need access to other injected services) than you can add setters to the enum and populate the cooker property in post construct.
-
\$\begingroup\$ For data layer, I also considered the first approach you mentioned: I think this is called "Joined Table Inheritance". How would the Restcontroller look like? Does client specify the Recipe type in query parameter, in RequestBody or should I have separate endpoints for every Recipe types I have? \$\endgroup\$user287004– user2870042024年10月08日 07:03:15 +00:00Commented Oct 8, 2024 at 7:03
-
\$\begingroup\$ the data layer design should not influence the design of REST api. the repository in the data layer should be able to get a recipee dto or recipee type and attrs , build the entities and save to db \$\endgroup\$Sharon Ben Asher– Sharon Ben Asher2024年10月08日 08:23:45 +00:00Commented Oct 8, 2024 at 8:23
-
\$\begingroup\$ But how does the recipeService.create() return the correct type if we only uses one endpoint ? I guess one solution is to pass the requested type(burger, pizza) as string in the query parameter? @PostMapping("/") public Recipe createRecipe(... type) { return recipeService.createRecipe(recipe, type); } \$\endgroup\$user287004– user2870042024年10月08日 10:27:08 +00:00Commented Oct 8, 2024 at 10:27
-
\$\begingroup\$ regardless of the design of service/data layer, the service should get the required type in arg. indeed i see recipeeService.create gets an instance of Recipee as arg. the dto should have type attribute. the last code line in my answer
recipe.getType().getCooker().cook(...)
uses the same variable that is used by the switch statement \$\endgroup\$Sharon Ben Asher– Sharon Ben Asher2024年10月08日 11:28:51 +00:00Commented Oct 8, 2024 at 11:28
To refactor this...
//THIS IS PROBABLY BAD DESIGN, BUT I HAVE NO IDEA HOW TO IMPROVE.
@Override
public String cook() throws Exception {
switch (recipe.getType()) {
case BURGER:
return cookBurger();
case PIZZA:
return cookPizza();
default:
throw new RecipeTypeNotExistException("Invalid recipe type: " + recipe.getType());
}
}
// Method for cooking Burger
private String cookBurger(){...}
// Method for cooking Pizza
private String cookPizza(){...}
...enhance the business model with a new abstraction that represents cookers...
interface Cooker {
Cooker EMPTY_COOKER = new Cooker() {
boolean cooks(RecipeType type) { return true; }
String cook(Recipe recipe) throws Exception {
throw new RecipeTypeNotExistException("Invalid recipe type: "
+ recipe.getType());
}
};
boolean cooks(RecipeType type);
String cook(Recipe recipe);
}
...with a corresponding implementation for each known cooker...
public class BurgerCooker implements Cooker {
boolean cooks(RecipeType type) { return type == RecipeType.BURGER; }
String cook(Recipe recipe) throws Exception { ... }
}
public class PizzaCooker implements Cooker {
boolean cooks(RecipeType type) { return type == RecipeType.PIZZA; }
String cook(Recipe recipe) throws Exception { ... }
}
...then enhance RecipeCooker
class with a constant to keep in memory an instance of each available cooker and an instance variable of type Cooker
that must be initialised by the constructor...
public class RecipeCooker {
private static final Cooker[] COOKERS = { new BurgerCooker()
, new PizzaCooker()
};
private final Recipe recipe;
private final Cooker cooker;
private final RecipeRepository recipeRepository;
public RecipeCooker(Recipe recipe
, RecipeRepository recipeRepository) {
this.recipe = recipe;
this.recipeRepository = recipeRepository;
Cooker toBeCooker = Cooker.EMPTY_COOKER;
for ( int i = 0; i < RecipeCooker.COOKERS.length; i++ ) {
if ( RecipeCooker.COOKERS[i].cooks(recipe.getType()) ) {
toBeCooker = RecipeCooker.COOKERS[i];
}
}
this.cooker = toBeCooker;
}
public String cook() throws Exception {
return this.cooker.cook();
}
}
That would be the simplest enhancement to keep consistent with the business model that includes a ReceipeCooker
model.
RecipeCooker
. What is the responsibility of the methodcook()
and why delegates to 3 different methods? Where you're using this method? HowrecipeService
andRecipeCooker
relate to each other? What is the expected outcome ofcookRecipe()
? Since it'sPOST
-request, then it probably changes something? \$\endgroup\$