构建器应接受原始类型还是值对象?Address构建器实现问询
Great question—this is a common point of tension when implementing builders, especially when balancing flexibility with domain integrity. Let’s break this down clearly.
Should the Builder Accept Primitives or Value Objects?
It depends entirely on where your builder will be used:
- For external input layers (forms, APIs): Accept primitives. When you’re dealing with raw strings/numbers from user input or external systems, primitives are easier to work with. You can handle parsing, validation, and conversion to value objects inside the builder—keeping that complexity contained instead of leaking it to callers.
- For internal domain code: Accept value objects. If your builder is used within your domain layer where you’re already working with validated, encapsulated value objects, this enforces that only valid data enters the Address. It aligns perfectly with your existing invariant protection.
The best approach? A hybrid one. Expose methods that accept primitives for external use, and add overloads that accept value objects for internal domain code. This gives you flexibility without sacrificing integrity.
AddressBuilder Implementation Recommendations
Here’s how to build a builder that plays nicely with your existing Address class:
1. Mirror Your Address Class’s Invariants
Since your Address already has a public constructor that enforces invariants via type hints and validation, the builder must not bypass these rules. When you call build(), delegate directly to the existing constructor—this ensures all validation logic lives in one place (the Address class) instead of being duplicated.
2. Use a Fluent Interface for Readability
Method chaining makes address creation read like natural language, which improves code maintainability. For example:
(new AddressBuilder())->firstLine("123 Main St")->postcode("SW1A 1AA")->city("London")->build();
3. Handle Optional Properties Gracefully
For optional fields (like secondLine, city, country), add setter methods that can be omitted. Initialize them to null or a sensible default (e.g., a default country) in the builder.
4. Integrate Tightly with Your Factory Method
Your existing factory method that accepts an AddressBuilder should simply delegate to the builder’s build() method. This keeps the integration clean and avoids duplicate logic.
5. Validate Early (If Using Primitives)
If you’re accepting primitives, add lightweight validation in the builder’s setter methods (or in build()) to catch obvious errors early. For example, ensure the first line isn’t empty, or the postcode matches a valid format. Throw clear exceptions so callers know exactly what went wrong.
Example Implementation (PHP)
Address Class (Existing)
class Address { public function __construct( public readonly string $firstLine, public readonly string $postcode, public readonly ?string $secondLine = null, public readonly ?string $city = null, public readonly ?string $country = "UK" ) { // Existing invariant checks if (empty($firstLine)) { throw new InvalidArgumentException("First line cannot be empty"); } if (!preg_match("/^[A-Z]{1,2}[0-9][A-Z0-9]? ?[0-9][A-Z]{2}$/i", $postcode)) { throw new InvalidArgumentException("Invalid postcode format"); } } // Factory method accepting builder public static function fromBuilder(AddressBuilder $builder): self { return $builder->build(); } }
AddressBuilder
class AddressBuilder { private ?string $firstLine = null; private ?string $postcode = null; private ?string $secondLine = null; private ?string $city = null; private ?string $country = "UK"; // Primitive setter for required first line public function firstLine(string $firstLine): self { if (empty($firstLine)) { throw new InvalidArgumentException("First line cannot be empty"); } $this->firstLine = $firstLine; return $this; } // Primitive setter for required postcode public function postcode(string $postcode): self { $this->postcode = $postcode; return $this; } // Optional property setters public function secondLine(?string $secondLine): self { $this->secondLine = $secondLine; return $this; } public function city(?string $city): self { $this->city = $city; return $this; } // Overload: Accept a Country value object public function country(Country $country): self { $this->country = $country->getCode(); return $this; } public function build(): Address { // Ensure required fields are set before building if ($this->firstLine === null || $this->postcode === null) { throw new LogicException("First line and postcode are required to create an Address"); } // Delegate to Address constructor for final validation return new Address( $this->firstLine, $this->postcode, $this->secondLine, $this->city, $this->country ); } }
Usage Examples
// Build from external input (primitives) $residentialAddress = (new AddressBuilder()) ->firstLine("456 Oak Ave") ->postcode("E1 6AN") ->city("London") ->build(); // Build via factory method $officeAddress = Address::fromBuilder( (new AddressBuilder()) ->firstLine("789 Pine Rd") ->postcode("W1 1AB") ); // Build with value objects (internal domain use) $france = new Country("FR"); $frenchAddress = (new AddressBuilder()) ->firstLine("10 Rue des Fleurs") ->postcode("75001") ->city("Paris") ->country($france) ->build();
Key Takeaway
Your builder should adapt to its context: primitives for ease of use with external input, value objects for strict domain integrity. By keeping the builder aligned with your Address class’s existing invariants, you ensure consistency no matter how you create Address instances.
内容的提问来源于stack exchange,提问作者Peppermintology

