Complex Example
June 02, 2023
Stepping Up the Complexity
Up to this point in the blog series, we’ve laid a little groundwork, and demonstrated a very simplistic example with only a couple classes collaborating and a test for a single use-case.
So, let’s start stressing the complexity of the problem.
You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design in order to create clean, well organized, and easy to read code. Use typescript with parameter property constructors where possible.
I run a coaching practice that consists of both full-time (FTE) and contract coaches. I have to recover the costs of the coaches across a number of funding sources within our organization that are tied to initiatives or value-streams.
Let’s call this software project CoachingPracticeFinanceTracker.
The system must handle several use-cases:
- add a new consultancy through which contractors will work, I need to know the name of the consultancy, our service agreement contract number, the primary contact, their phone number, and their email
- get a list of registered consultancies
- add a new contractor to a consultancy, I need to know the numeric login id of the contractor, their name, what consultancy they work through, their phone number and email, their transaction agreement contract number, start, and end dates
- get a list of registered contractors for a given consultancy
Generate a class per use-case, and a test suite per use-case that ensures the use-case class validates the data model and any boundary conditions of the use-case.
First Zero-Shot Run, Generated Output
*The first prompt ran out of context space, but ChatGPT-4 now allows you to continue generating in a second transaction.
Here’s the full output:*
Sure, let’s define a few model classes and the use-case classes according to the specifications you provided.
Here’s the model classes for the Consultancy and the Contractor:
interface ContactInfo {name: string;phoneNumber: string;email: string;}class Consultancy {constructor(public readonly name: string,public readonly contractNumber: string,public readonly primaryContact: ContactInfo) {}}class Contractor {constructor(public readonly loginId: number,public readonly name: string,public readonly consultancy: Consultancy,public readonly transactionContractNumber: string,public readonly contactInfo: ContactInfo,public readonly transactionStartDate: Date,public readonly transactionEndDate: Date) {}}
Here’s the use-case classes for adding a consultancy and a contractor, and retrieving registered consultancies and contractors:
class AddConsultancyUseCase {constructor(private consultancies: Consultancy[] = []) {}execute(consultancy: Consultancy): void {// Here you could add some validation logic if neededthis.consultancies.push(consultancy);}}class AddContractorUseCase {constructor(private contractors: Contractor[] = []) {}execute(contractor: Contractor): void {// Here you could add some validation logic if neededthis.contractors.push(contractor);}}class GetConsultanciesUseCase {constructor(private consultancies: Consultancy[] = []) {}execute(): Consultancy[] {return this.consultancies;}}class GetContractorsForConsultancyUseCase {constructor(private contractors: Contractor[] = []) {}execute(consultancy: Consultancy): Contractor[] {return this.contractors.filter((contractor) => contractor.consultancy === consultancy);}}
Here’s a simple test suite for each use-case, using the Jest testing framework:
import { Consultancy, Contractor } from './models';import {AddConsultancyUseCase,AddContractorUseCase,GetConsultanciesUseCase,GetContractorsForConsultancyUseCase,} from './use-cases';describe('AddConsultancyUseCase', () => {test('should add a consultancy', () => {const useCase = new AddConsultancyUseCase();const consultancy = new Consultancy('test', '123', {name: 'John Doe',phoneNumber: '123-456-7890',email: 'john.doe@example.com',});useCase.execute(consultancy);expect(useCase.execute().length).toBe(1);});});describe('AddContractorUseCase', () => {test('should add a contractor', () => {const useCase = new AddContractorUseCase();const consultancy = new Consultancy('test', '123', {name: 'John Doe',phoneNumber: '123-456-7890',email: 'john.doe@example.com',});const contractor = new Contractor(1,'Jane Doe',consultancy,'321',{name: 'Jane Doe',phoneNumber: '098-765-4321',email: 'jane.doe@example.com',},new Date(),new Date());useCase.execute(contractor);expect(useCase.execute().length).toBe(1);});});describe('GetConsultanciesUseCase', () => {test('should retrieve all consultancies', () => {const useCase = new GetConsultanciesUseCase();expect(useCase.execute().length).toBe(0);});});describe('GetContractorsForConsultancyUseCase', () => {test('should retrieve contractors for a given consultancy', () => {const consultancyUseCase = new AddConsultancyUseCase();const consultancy = new Consultancy('test', '123', {name: 'John Doe',phoneNumber: '123-456-7890',email: 'john.doe@example.com',});consultancyUseCase.execute(consultancy);const contractorUseCase = new AddContractorUseCase();const contractor = new Contractor(1,'Jane Doe',consultancy,'321',{name: 'Jane Doe',phoneNumber: '098-765-4321',email: 'jane.doe@example.com',},new Date(),new Date());contractorUseCase.execute(contractor);const getContractorsUseCase = new GetContractorsForConsultancyUseCase(contractorUseCase.execute());expect(getContractorsUseCase.execute(consultancy).length).toBe(1);});});
This is a simplified model to demonstrate the structure of use-case classes and associated tests. In a real-world scenario, you might want to add more robust validation, error handling, and persistence layers.
Analyzing the First Output
Notice how the LLM wanted to structure the contact information for a consultancy into a single interface. However it did not attempt to structure the contract information for the contractor. This is an interesting result.
Let’s look at the use-case area of the prompt:
- add a new consultancy through which contractors will work, I need to know the name of the consultancy, our service agreement contract number, the primary contact, their phone number, and their email
- add a contractor to a consultancy, I need to know the numeric login id of the contractor, their name, what consultancy they work through, their transaction agreement contract number, their phone number, their email, and the start and end dates of the transaction agreement
Let’s rewrite the add contractor prompt to look more like the add consultancy prompt.
- add a new consultancy through which contractors will work, I need to know the name of the consultancy, our service agreement contract number, the primary contact, their phone number, and their email
- add a new contractor to a consultancy, I need to know the numeric login id of the contractor, their name, what consultancy they work through, their phone number and email, their transaction agreement contract number, start, and end dates
Let’s try the updated prompt and see what gets generated:
Second Zero-Shot Run, Generated Output
Here’s an outline of how you might design a simple system based on the use-cases described above using TypeScript. The following includes TypeScript classes for each use-case along with Jest tests. We start with defining two data models: Consultancy and Contractor.
export interface Consultancy {name: string;contractNumber: string;primaryContact: string;phoneNumber: string;email: string;}export interface Contractor {loginId: number;name: string;consultancy: string;phoneNumber: string;email: string;contractNumber: string;startDate: Date;endDate: Date;}
For the AddConsultancy
use-case:
export class AddConsultancy {constructor(private consultancies: Consultancy[]) {}execute(consultancy: Consultancy): void {// Validate the input...// Add the consultancy to the list...this.consultancies.push(consultancy);}}
And the test suite for AddConsultancy
:
import { AddConsultancy } from './AddConsultancy';import { Consultancy } from './models';test('Should add consultancy', () => {const consultancies: Consultancy[] = [];const addConsultancy = new AddConsultancy(consultancies);const newConsultancy: Consultancy = {name: 'Test Consultancy',contractNumber: '1234',primaryContact: 'John Doe',phoneNumber: '1234567890',email: 'john@doe.com'};addConsultancy.execute(newConsultancy);expect(consultancies.length).toBe(1);expect(consultancies[0]).toBe(newConsultancy);});
For the ListConsultancies
use-case:
export class ListConsultancies {constructor(private consultancies: Consultancy[]) {}execute(): Consultancy[] {return this.consultancies;}}
And the test suite for ListConsultancies
:
import { ListConsultancies } from './ListConsultancies';import { Consultancy } from './models';test('Should list consultancies', () => {const consultancies: Consultancy[] = [{name: 'Test Consultancy',contractNumber: '1234',primaryContact: 'John Doe',phoneNumber: '1234567890',email: 'john@doe.com'}];const listConsultancies = new ListConsultancies(consultancies);const result = listConsultancies.execute();expect(result.length).toBe(1);expect(result).toBe(consultancies);});
Let’s continue with the AddContractor
and ListContractors
use-case. Notice how the Contractor
model has a consultancy
field that will allow you to filter contractors for a specific consultancy:
export class AddContractor {constructor(private contractors: Contractor[]) {}execute(contractor: Contractor): void {// Validate the input...// Add the contractor to the list...this.contractors.push(contractor);}}
Test suite for AddContractor
:
import { AddContractor } from './AddContractor';import { Contractor } from './models';test('Should add contractor', () => {const contractors: Contractor[] = [];const addContractor = new AddContractor(contractors);const newContractor: Contractor = {loginId: 1,name: 'Test Contractor',consultancy: 'Test Consultancy',phoneNumber: '1234567890',email: 'contractor@doe.com',contractNumber: '5678',startDate: new Date(),endDate: new Date()};addContractor.execute(newContractor);expect(contractors.length).toBe(1);expect(contractors[0]).toBe(newContractor);});
Lastly, we add the ListContractors
use-case:
export class ListContractors {constructor(private contractors: Contractor[]) {}execute(consultancy: string): Contractor[] {return this.contractors.filter(c => c.consultancy === consultancy);}}
Test suite for ListContractors
:
import { ListContractors } from './ListContractors';import { Contractor } from './models';test('Should list contractors for a given consultancy', () => {const contractors: Contractor[] = [{loginId: 1,name: 'Test Contractor',consultancy: 'Test Consultancy',phoneNumber: '1234567890',email: 'contractor@doe.com',contractNumber: '5678',startDate: new Date(),endDate: new Date()}];const listContractors = new ListContractors(contractors);const result = listContractors.execute('Test Consultancy');expect(result.length).toBe(1);expect(result).toBe(contractors);});
This is a simplified approach and assumes in-memory storage. In real-world scenarios, you’d likely be interacting with a database and the implementation of the execute
methods would be more complex, including validation and error handling.
Also, the system lacks a central point of coordination (like a service layer or controller), and that’s something you’d definitely need for a real application. But for the purpose of demonstrating how to use TypeScript to model these use-cases and their corresponding test cases, I hope this proves useful.
Third Zero-Shot Run, Generated Output
Here is a basic implementation of the use-cases you have described using TypeScript. This includes the definition of several classes to model your problem and the use-cases you have defined.
Let’s start with some basic classes to represent the entities in your system:
class Consultancy {constructor(public name: string,public serviceAgreementContractNumber: string,public primaryContact: string,public phoneNumber: string,public email: string) {}}class Contractor {constructor(public loginId: number,public name: string,public consultancy: Consultancy,public transactionAgreementContractNumber: string,public phoneNumber: string,public email: string,public start: Date,public end: Date) {}}
Next, we can implement the use-cases:
class AddConsultancyUseCase {consultancies: Consultancy[] = [];execute(consultancy: Consultancy) {this.consultancies.push(consultancy);}}class GetConsultanciesUseCase {constructor(private addConsultancyUseCase: AddConsultancyUseCase) {}execute(): Consultancy[] {return this.addConsultancyUseCase.consultancies;}}class AddContractorUseCase {contractors: Contractor[] = [];execute(contractor: Contractor) {this.contractors.push(contractor);}}class GetContractorsUseCase {constructor(private addContractorUseCase: AddContractorUseCase) {}execute(consultancy: Consultancy): Contractor[] {return this.addContractorUseCase.contractors.filter((contractor) => contractor.consultancy === consultancy);}}
Then, we can create tests for each of these use-cases. We’ll use a simple assertion function for these tests, but you can use any testing library of your choice:
function assert(condition: any, message: string): void {if (!condition) {throw new Error(message);}}function testAddConsultancyUseCase(): void {let useCase = new AddConsultancyUseCase();let consultancy = new Consultancy('Consultancy A', '123', 'John Doe', '123456789', 'john@doe.com');useCase.execute(consultancy);assert(useCase.consultancies.length === 1, 'Consultancy was not added correctly');}function testGetConsultanciesUseCase(): void {let addConsultancyUseCase = new AddConsultancyUseCase();let getConsultanciesUseCase = new GetConsultanciesUseCase(addConsultancyUseCase);let consultancy = new Consultancy('Consultancy A', '123', 'John Doe', '123456789', 'john@doe.com');addConsultancyUseCase.execute(consultancy);let consultancies = getConsultanciesUseCase.execute();assert(consultancies.length === 1, 'Consultancies were not retrieved correctly');}function testAddContractorUseCase(): void {let useCase = new AddContractorUseCase();let consultancy = new Consultancy('Consultancy A', '123', 'John Doe', '123456789', 'john@doe.com');let contractor = new Contractor(1, 'Jane Doe', consultancy, '234', '987654321', 'jane@doe.com', new Date(), new Date());useCase.execute(contractor);assert(useCase.contractors.length === 1, 'Contractor was not added correctly');}function testGetContractorsUseCase(): void {let addContractorUseCase = new AddContractorUseCase();let getContractorsUseCase = new GetContractorsUseCase(addContractorUseCase);let consultancy = new Consultancy('Consultancy A', '123', 'John Doe', '123456789', 'john@doe.com');let contractor = new Contractor(1, 'Jane Doe', consultancy, '234', '987654321', 'jane@doe.com', new Date(), new Date());addContractorUseCase.execute(contractor);let contractors = getContractorsUseCase.execute(consultancy);assert(contractors.length === 1, 'Contractors were not retrieved correctly');}// Run teststestAddConsultancyUseCase();testGetConsultanciesUseCase();testAddContractorUseCase();testGetContractorsUseCase();
This code doesn’t handle any exceptions nor has a proper validation for the input parameters, so be sure to add that as you evolve the software.
An Examination of the First Three Runs
I was generally unhappy with all three runs, but they gave me some ideas as to how to control the output.
Run 1 Pros
- picked up on the same basic entities I’d have done
- good names
- chose jest framework
Run 1 Cons
- the ContactInfo interface for a single field in the Consultancy object seemed inconsistent
- the constructors for the use-cases each have their own list of entities
- no validation logic in the use cases
Run 2 Pros
- didn’t really see any
Run 2 Cons
- used interfaces for entities and members, I’m not yet sure how I feel about it - like would it be better to later segregate them? I do enjoy the compositional opportunities with interfaces
- the constructors for the use-cases each have their own list of entities
- didn’t automatically choose jest (or a framework with describe/it)
Run 3 Pros
- clean & flat Consultancy and Contractor class definitions
Run 3 Cons
- puts the list of entities in the add use-cases and awkwardly references it in the get use-cases
- rolled its own test framework
Adjustments to Context
Based on these runs, I want to adjust my baseline system context with some specific instruction:
- use jest
- glue the entities together in a single-rooted entity graph so that I can load and save the whole graph with a single data file
- the use-case constructors should all take the root object of the entity graph
- don’t yet structure the types, and express the types as interfaces; this will be more lightweight than classes, let’s see if we get ourselves into any design difficulty
Next Prompt, Starting Again
You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design in order to create clean, well organized, and easy to read code. Use typescript with parameter property constructors where possible. Use Jest for testing. Composite data types should be expressed as interfaces. The data used by the application should be rooted by a single javascript object so that the whole object graph can be loaded and saved from a single json data file.
I run a coaching practice that consists of both full-time (FTE) and contract coaches. I have to recover the costs of the coaches across a number of funding sources within our organization that are tied to initiatives or value-streams.
Let’s call this software project CoachingPracticeFinanceTracker, and the root data object type CoachingPracticeFinance.
The system must handle several use-cases:
- add a new consultancy through which contractors will work, I need to know the name of the consultancy, our service agreement contract number, the primary contact, their phone number, and their email
- get a list of registered consultancies
- add a new contractor to a consultancy, I need to know the numeric login id of the contractor, their name, what consultancy they work through, their phone number and email, their transaction agreement contract number, start, and end dates
- get a list of registered contractors for a given consultancy
- get an aggregate list of all contractors across all consultancies
Generate a class per use-case, with a constructor that takes the root object of the data graph as an argument. Generate a test suite per use-case that ensures the use-case class validates the data model and any boundary conditions of the use-case.
Fourth Zero-Shot Run, Generated Output
Alright, I’ll start with creating the data interfaces and then I’ll proceed with the implementation of the use-case classes and their respective test suites.
First, let’s define the interfaces for the data model:
interface ContactDetails {phoneNumber: string;email: string;}interface Consultancy {name: string;contractNumber: string;primaryContact: string;contactDetails: ContactDetails;}interface Contractor {loginId: number;name: string;consultancyName: string;contactDetails: ContactDetails;transactionAgreementContractNumber: string;startDate: Date;endDate: Date;}interface CoachingPracticeFinance {consultancies: Consultancy[];contractors: Contractor[];}
Now, let’s proceed with the implementation of the use-case classes.
The AddConsultancy
class:
class AddConsultancy {private _financeData: CoachingPracticeFinance;constructor(financeData: CoachingPracticeFinance) {this._financeData = financeData;}addConsultancy(consultancy: Consultancy): void {this._financeData.consultancies.push(consultancy);}}
The GetConsultancies
class:
class GetConsultancies {private _financeData: CoachingPracticeFinance;constructor(financeData: CoachingPracticeFinance) {this._financeData = financeData;}getConsultancies(): Consultancy[] {return this._financeData.consultancies;}}
The AddContractor
class:
class AddContractor {private _financeData: CoachingPracticeFinance;constructor(financeData: CoachingPracticeFinance) {this._financeData = financeData;}addContractor(contractor: Contractor): void {const consultancy = this._financeData.consultancies.find(c => c.name === contractor.consultancyName);if (!consultancy) throw new Error(`Consultancy ${contractor.consultancyName} does not exist.`);this._financeData.contractors.push(contractor);}}
The GetContractors
class:
class GetContractors {private _financeData: CoachingPracticeFinance;constructor(financeData: CoachingPracticeFinance) {this._financeData = financeData;}getContractors(consultancyName: string): Contractor[] {return this._financeData.contractors.filter(c => c.consultancyName === consultancyName);}}
Now, let’s create Jest test suites for each of the classes.
The test suite for AddConsultancy
:
describe("AddConsultancy", () => {let data: CoachingPracticeFinance;let addConsultancy: AddConsultancy;beforeEach(() => {data = { consultancies: [], contractors: [] };addConsultancy = new AddConsultancy(data);});test("adds a consultancy", () => {const consultancy: Consultancy = { name: "Test", contractNumber: "123", primaryContact: "Bob", contactDetails: { phoneNumber: "123456789", email: "bob@test.com" }};addConsultancy.addConsultancy(consultancy);expect(data.consultancies).toContain(consultancy);});});
The test suite for GetConsultancies
:
describe("GetConsultancies", () => {let data: CoachingPracticeFinance;let getConsultancies: GetConsultancies;beforeEach(() => {data = { consultancies: [], contractors: [] };getConsultancies = new GetConsultancies(data);});test("gets all consultancies", () => {const consultancy: Consultancy = { name: "Test", contractNumber: "123", primaryContact: "Bob", contactDetails: { phoneNumber: "123456789", email: "bob@test.com" }};data.consultancies.push(consultancy);expect(getConsultancies.getConsultancies()).toContain(consultancy);});});
The test suite for AddContractor
:
describe("AddContractor", () => {let data: CoachingPracticeFinance;let addContractor: AddContractor;beforeEach(() => {data = { consultancies: [], contractors: [] };addContractor = new AddContractor(data);});test("adds a contractor", () => {const consultancy: Consultancy = { name: "Test", contractNumber: "123", primaryContact: "Bob", contactDetails: { phoneNumber: "123456789", email: "bob@test.com" }};data.consultancies.push(consultancy);const contractor: Contractor = { loginId: 1, name: "Alice", consultancyName: "Test", contactDetails: { phoneNumber: "987654321", email: "alice@test.com" }, transactionAgreementContractNumber: "456", startDate: new Date(), endDate: new Date() };addContractor.addContractor(contractor);expect(data.contractors).toContain(contractor);});test("throws error when consultancy does not exist", () => {const contractor: Contractor = { loginId: 1, name: "Alice", consultancyName: "Nonexistent", contactDetails: { phoneNumber: "987654321", email: "alice@test.com" }, transactionAgreementContractNumber: "456", startDate: new Date(), endDate: new Date() };expect(() => addContractor.addContractor(contractor)).toThrowError("Consultancy Nonexistent does not exist.");});});
The test suite for GetContractors
:
describe("GetContractors", () => {let data: CoachingPracticeFinance;let getContractors: GetContractors;beforeEach(() => {data = { consultancies: [], contractors: [] };getContractors = new GetContractors(data);});test("gets all contractors of a consultancy", () => {const consultancy: Consultancy = { name: "Test", contractNumber: "123", primaryContact: "Bob", contactDetails: { phoneNumber: "123456789", email: "bob@test.com" }};data.consultancies.push(consultancy);const contractor1: Contractor = { loginId: 1, name: "Alice", consultancyName: "Test", contactDetails: { phoneNumber: "987654321", email: "alice@test.com" }, transactionAgreementContractNumber: "456", startDate: new Date(), endDate: new Date() };const contractor2: Contractor = { loginId: 2, name: "Charlie", consultancyName: "Test", contactDetails: { phoneNumber: "135792468", email: "charlie@test.com" }, transactionAgreementContractNumber: "789", startDate: new Date(), endDate: new Date() };data.contractors.push(contractor1, contractor2);expect(getContractors.getContractors("Test")).toContain(contractor1, contractor2);});test("does not return contractors of other consultancies", () => {const consultancy1: Consultancy = { name: "Test", contractNumber: "123", primaryContact: "Bob", contactDetails: { phoneNumber: "123456789", email: "bob@test.com" }};const consultancy2: Consultancy = { name: "Another", contractNumber: "234", primaryContact: "Dave", contactDetails: { phoneNumber: "246813579", email: "dave@another.com" }};data.consultancies.push(consultancy1, consultancy2);const contractor1: Contractor = { loginId: 1, name: "Alice", consultancyName: "Test", contactDetails: { phoneNumber: "987654321", email: "alice@test.com" }, transactionAgreementContractNumber: "456", startDate: new Date(), endDate: new Date() };const contractor2: Contractor = { loginId: 2, name: "Charlie", consultancyName: "Another", contactDetails: { phoneNumber: "135792468", email: "charlie@another.com" }, transactionAgreementContractNumber: "789", startDate: new Date(), endDate: new Date() };data.contractors.push(contractor1, contractor2);expect(getContractors.getContractors("Test")).toContain(contractor1);expect(getContractors.getContractors("Test")).not.toContain(contractor2);});});
Please let me know if you need further clarification or additional functionalities!
Add a New Requirement
Let’s add a new requirement and re-run this to see if our output is consistent.
- get an aggregate list of all contractors across all consultancies
Fifth Zero-Shot Run, Generated Output
To complete your request, I will create several TypeScript classes and Jest tests for each use case, with interfaces for composite data types.
For this purpose, first, let’s define the interfaces that will make up our data model.
interface Consultancy {name: string;contractNumber: string;primaryContact: string;phoneNumber: string;email: string;}interface Contractor {loginId: number;name: string;consultancy: Consultancy;phoneNumber: string;email: string;transactionAgreementContractNumber: string;startDate: Date;endDate: Date;}interface CoachingPracticeFinance {consultancies: Consultancy[];contractors: Contractor[];}
Now, we can create classes for each use case, as per your requirement.
class AddConsultancyUseCase {constructor(private root: CoachingPracticeFinance) {}execute(consultancy: Consultancy): void {this.root.consultancies.push(consultancy);}}class GetConsultanciesUseCase {constructor(private root: CoachingPracticeFinance) {}execute(): Consultancy[] {return this.root.consultancies;}}class AddContractorUseCase {constructor(private root: CoachingPracticeFinance) {}execute(contractor: Contractor): void {this.root.contractors.push(contractor);}}class GetContractorsByConsultancyUseCase {constructor(private root: CoachingPracticeFinance) {}execute(consultancy: Consultancy): Contractor[] {return this.root.contractors.filter(contractor => contractor.consultancy === consultancy);}}class GetAllContractorsUseCase {constructor(private root: CoachingPracticeFinance) {}execute(): Contractor[] {return this.root.contractors;}}
Now let’s create test suites for each use case.
import { AddConsultancyUseCase, GetConsultanciesUseCase, AddContractorUseCase, GetContractorsByConsultancyUseCase, GetAllContractorsUseCase } from './useCases';import { CoachingPracticeFinance, Consultancy, Contractor } from './dataModel';describe('AddConsultancyUseCase', () => {it('adds a consultancy to the root data', () => {const root: CoachingPracticeFinance = { consultancies: [], contractors: [] };const consultancy: Consultancy = { /* Consultancy data... */ };const useCase = new AddConsultancyUseCase(root);useCase.execute(consultancy);expect(root.consultancies).toContain(consultancy);});});describe('GetConsultanciesUseCase', () => {// similar tests...});describe('AddContractorUseCase', () => {// similar tests...});describe('GetContractorsByConsultancyUseCase', () => {// similar tests...});describe('GetAllContractorsUseCase', () => {// similar tests...});
In these test suites, we construct an instance of each use case and verify that they correctly manipulate the root data object. Replace // similar tests...
with test cases for other use cases.
This is a simplified solution. You should add error handling, validation logic, and possibly more complex business logic in your actual application.
Hmm, that was short
No continuations needed at all in that last run, as opposed to the 4th run which required 3 of them.
Let’s try re-generating from the same prompt.
Sixth Zero-Shot Run, Generated Output
We can start with defining the interfaces for the complex data types we’ll be working with:
interface Consultancy {name: string;serviceAgreementContractNumber: string;primaryContact: string;phoneNumber: string;email: string;}interface Contractor {loginId: number;name: string;consultancy: string;phoneNumber: string;email: string;transactionAgreementContractNumber: string;startDate: string;endDate: string;}interface CoachingPracticeFinance {consultancies: Consultancy[];contractors: Contractor[];}
Next, we can create the classes for each use case.
class AddConsultancy {constructor(private coachingPracticeFinance: CoachingPracticeFinance) {}execute(newConsultancy: Consultancy): void {// Validation omitted for brevitythis.coachingPracticeFinance.consultancies.push(newConsultancy);}}class GetConsultancies {constructor(private coachingPracticeFinance: CoachingPracticeFinance) {}execute(): Consultancy[] {return this.coachingPracticeFinance.consultancies;}}class AddContractor {constructor(private coachingPracticeFinance: CoachingPracticeFinance) {}execute(newContractor: Contractor): void {// Validation omitted for brevitythis.coachingPracticeFinance.contractors.push(newContractor);}}class GetContractorsForConsultancy {constructor(private coachingPracticeFinance: CoachingPracticeFinance) {}execute(consultancyName: string): Contractor[] {return this.coachingPracticeFinance.contractors.filter(contractor => contractor.consultancy === consultancyName);}}class GetAllContractors {constructor(private coachingPracticeFinance: CoachingPracticeFinance) {}execute(): Contractor[] {return this.coachingPracticeFinance.contractors;}}
We can then write Jest tests for these use cases.
import { AddConsultancy, GetConsultancies, AddContractor, GetContractorsForConsultancy, GetAllContractors } from './useCases';import { CoachingPracticeFinance, Consultancy, Contractor } from './models';let data: CoachingPracticeFinance;beforeEach(() => {data = {consultancies: [],contractors: []};});test('Add and get consultancies', () => {const addConsultancy = new AddConsultancy(data);const getConsultancies = new GetConsultancies(data);const consultancy: Consultancy = {// properties omitted for brevity};addConsultancy.execute(consultancy);expect(getConsultancies.execute()).toContain(consultancy);});// similar tests for other use cases
This is a simple, but not yet complete example of how you could go about organizing this system. Error handling and input validation would be needed, as well as more complex business logic. However, it does give a good starting point.
Seventh Zero-Shot Run, Generated Output
Sure, let’s design some classes and interfaces in TypeScript to achieve this. We’ll be creating a few classes and interfaces to model the business requirements. I will use a CoachingPracticeFinance
class to act as our root class, with nested classes and interfaces to handle consultancies and contractors.
First, we need to define the interfaces for Contractor
and Consultancy
.
interface Contractor {id: number;name: string;consultancyName: string;phoneNumber: string;email: string;transactionAgreementContractNumber: string;startDate: Date;endDate: Date;}interface Consultancy {name: string;serviceAgreementContractNumber: string;primaryContact: string;phoneNumber: string;email: string;}
Now let’s define our CoachingPracticeFinance
class:
class CoachingPracticeFinance {consultancies: Consultancy[];contractors: Contractor[];constructor() {this.consultancies = [];this.contractors = [];}}
For each use-case, we create a separate class that encapsulates the logic for that use-case:
class AddConsultancy {constructor(private finance: CoachingPracticeFinance) {}execute(consultancy: Consultancy) {this.finance.consultancies.push(consultancy);}}class GetRegisteredConsultancies {constructor(private finance: CoachingPracticeFinance) {}execute() {return this.finance.consultancies;}}class AddContractor {constructor(private finance: CoachingPracticeFinance) {}execute(contractor: Contractor) {this.finance.contractors.push(contractor);}}class GetRegisteredContractorsForConsultancy {constructor(private finance: CoachingPracticeFinance) {}execute(consultancyName: string) {return this.finance.contractors.filter(contractor => contractor.consultancyName === consultancyName);}}class GetAggregateContractors {constructor(private finance: CoachingPracticeFinance) {}execute() {return this.finance.contractors;}}
Now let’s write Jest tests to validate these classes:
const finance = new CoachingPracticeFinance();describe('AddConsultancy', () => {it('should add consultancy to the finance', () => {const consultancy = { name: 'ABC Consultancy', serviceAgreementContractNumber: 'ABC123', primaryContact: 'John', phoneNumber: '1234567890', email: 'john@abc.com' };const addConsultancy = new AddConsultancy(finance);addConsultancy.execute(consultancy);expect(finance.consultancies).toContain(consultancy);});});describe('GetRegisteredConsultancies', () => {it('should return all registered consultancies', () => {const getRegisteredConsultancies = new GetRegisteredConsultancies(finance);expect(getRegisteredConsultancies.execute()).toEqual(finance.consultancies);});});describe('AddContractor', () => {it('should add contractor to the finance', () => {const contractor = { id: 1, name: 'Alex', consultancyName: 'ABC Consultancy', phoneNumber: '0987654321', email: 'alex@abc.com', transactionAgreementContractNumber: 'XYZ123', startDate: new Date(), endDate: new Date() };const addContractor = new AddContractor(finance);addContractor.execute(contractor);expect(finance.contractors).toContain(contractor);});});describe('GetRegisteredContractorsForConsultancy', () => {it('should return all registered contractors for a given consultancy', () =>{const consultancyName = 'ABC Consultancy';const getRegisteredContractorsForConsultancy = new GetRegisteredContractorsForConsultancy(finance);expect(getRegisteredContractorsForConsultancy.execute(consultancyName)).toEqual(finance.contractors.filter(contractor => contractor.consultancyName === consultancyName));});});describe('GetAggregateContractors', () => {it('should return all contractors', () => {const getAggregateContractors = new GetAggregateContractors(finance);expect(getAggregateContractors.execute()).toEqual(finance.contractors);});});
This is a basic example to demonstrate how we can model these use-cases with classes and interfaces in TypeScript. You may need to add error handling, input validation, and additional business logic as necessary.
Analysis of Fifth, Sixth and Seventh Runs
The first two runs had a short output, and the third run required a continuation.
Generation of the data-structures with interfaces has become somewhat stable. This makes sense, given our expanded language around how to construct the data-structures:
Composite data types should be expressed as interfaces. The data used by the application should be rooted by a single javascript object so that the whole object graph can be loaded and saved from a single json data file.
Generation of the test cases remains inconsistent. This is the expanded language around how to construct tests:
Use Jest for testing.
Let’s adjust this somewhat.
Here’s my next prompt:
You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design in order to create clean, well organized, and easy to read code.
Language and style: Use typescript with parameter property constructors where possible. Tests: Use Jest for testing, use
describe
andit
blocks, and generate example data for each scenario. Nestdescribe
scenarios when scenarios share a common context. Data structures: Composite data types should be expressed as interfaces. The data used by the application should be rooted by a single javascript object so that the whole object graph can be loaded and saved from a single json data file.Application domain: I run a coaching practice that consists of both full-time (FTE) and contract coaches. I have to recover the costs of the coaches across a number of funding sources within our organization that are tied to initiatives or value-streams.
Requirements: Let’s call this software project CoachingPracticeFinanceTracker, and the root data object type CoachingPracticeFinance.
The system must handle several use-cases:
- add a new consultancy through which contractors will work, I need to know the name of the consultancy, our service agreement contract number, the primary contact, their phone number, and their email
- get a list of registered consultancies
- add a new contractor to a consultancy, I need to know the numeric login id of the contractor, their name, what consultancy they work through, their phone number and email, their transaction agreement contract number, start, and end dates
- get a list of registered contractors for a given consultancy
- get an aggregate list of all contractors across all consultancies
Instructions: Generate a class per use-case, with a constructor that takes the root object of the data graph as an argument. Generate a test suite per use-case that ensures the use-case class validates the data model and any boundary conditions of the use-case.
Eighth Zero-Shot Run, Generated Output
This is a great description of the application domain and requirements. Let’s start creating these use-case classes and test suites.
First, let’s define the necessary interfaces:
interface Contact {name: string;phoneNumber: string;email: string;}interface Consultancy {id: string;serviceAgreementContractNumber: string;primaryContact: Contact;}interface Contractor {loginId: number;name: string;phoneNumber: string;email: string;transactionAgreementContractNumber: string;startDate: Date;endDate: Date;consultancyId: string;}interface CoachingPracticeFinance {consultancies: Consultancy[];contractors: Contractor[];}
Now, let’s create the use-case classes. Starting with the AddConsultancy
class:
class AddConsultancy {constructor(private finance: CoachingPracticeFinance) {}execute(consultancy: Consultancy): void {this.finance.consultancies.push(consultancy);}}
Here’s the test suite for AddConsultancy
:
describe('AddConsultancy', () => {let finance: CoachingPracticeFinance;beforeEach(() => {finance = { consultancies: [], contractors: [] };});it('should add a consultancy', () => {const consultancy: Consultancy = {id: '123',serviceAgreementContractNumber: '456',primaryContact: {name: 'John Doe',phoneNumber: '555-555-5555',email: 'john@doe.com'}};const addConsultancy = new AddConsultancy(finance);addConsultancy.execute(consultancy);expect(finance.consultancies).toContain(consultancy);});});
Continuing with AddContractor
class:
class AddContractor {constructor(private finance: CoachingPracticeFinance) {}execute(contractor: Contractor): void {if (!this.finance.consultancies.some(c => c.id === contractor.consultancyId)) {throw new Error('Invalid consultancy ID');}this.finance.contractors.push(contractor);}}
And the test suite for AddContractor
:
describe('AddContractor', () => {let finance: CoachingPracticeFinance;let consultancy: Consultancy;beforeEach(() => {consultancy = {id: '123',serviceAgreementContractNumber: '456',primaryContact: {name: 'John Doe',phoneNumber: '555-555-5555',email: 'john@doe.com'}};finance = { consultancies: [consultancy], contractors: [] };});it('should add a contractor', () => {const contractor: Contractor = {loginId: 1,name: 'Jane Smith',phoneNumber: '555-555-5556',email: 'jane@smith.com',transactionAgreementContractNumber: '789',startDate: new Date(),endDate: new Date(),consultancyId: consultancy.id};const addContractor = new AddContractor(finance);addContractor.execute(contractor);expect(finance.contractors).toContain(contractor);});it('should throw an error when consultancy id is invalid', () => {const contractor: Contractor = {loginId: 1,name: 'Jane Smith',phoneNumber: '555-555-5556',email: 'jane@smith.com',transactionAgreementContractNumber: '789',startDate: new Date(),endDate: new Date(),consultancyId: 'invalid'};const addContractor = new AddContractor(finance);expect(() => addContractor.execute(contractor)).toThrow('Invalid consultancy ID');});});
The remaining classes and test suites for retrieving the consultancies and contractors can be created in a similar fashion. Would you like me to continue generating them?
Analyzing the Eighth Run
Isn’t that nice, it asked us if we wanted to continue generating test cases? I did already have to request a single continuation due to the volume of output.
The tests are more of the shape I like to see, but I’m noticing how many lines of code we’re spending synthesizing test data. The challenge with long tests, is you do begin to lose the point of them in the noise of the code within them.
Dealing with the volume of test data definition inside your test cases can be a difficult stylistic decision, utilizing test-data generating strategies like Faker can reduce the noise but can also make your tests less deterministic. Let’s leave the explicit structures in there for now.
It did not create much of the implementation compared to the other runs, so let’s ask it to continue with a chat message back.
Continuing Eighth Zero-Shot Run
Alright, let’s move on to the GetConsultancies
class:
class GetConsultancies {constructor(private finance: CoachingPracticeFinance) {}execute(): Consultancy[] {return this.finance.consultancies;}}
And the test suite for GetConsultancies
:
describe('GetConsultancies', () => {let finance: CoachingPracticeFinance;beforeEach(() => {finance = {consultancies: [{id: '123',serviceAgreementContractNumber: '456',primaryContact: {name: 'John Doe',phoneNumber: '555-555-5555',email: 'john@doe.com'}},{id: '789',serviceAgreementContractNumber: '012',primaryContact: {name: 'Jane Smith',phoneNumber: '555-555-5556',email: 'jane@smith.com'}}],contractors: []};});it('should get all consultancies', () => {const getConsultancies = new GetConsultancies(finance);const consultancies = getConsultancies.execute();expect(consultancies.length).toEqual(2);expect(consultancies).toEqual(finance.consultancies);});});
Next is the GetContractors
class:
class GetContractors {constructor(private finance: CoachingPracticeFinance) {}execute(consultancyId: string): Contractor[] {return this.finance.contractors.filter(c => c.consultancyId === consultancyId);}}
And the test suite for GetContractors
:
describe('GetContractors', () => {let finance: CoachingPracticeFinance;let consultancy: Consultancy;let contractor: Contractor;beforeEach(() => {consultancy = {id: '123',serviceAgreementContractNumber: '456',primaryContact: {name: 'John Doe',phoneNumber: '555-555-5555',email: 'john@doe.com'}};contractor = {loginId: 1,name: 'Jane Smith',phoneNumber: '555-555-5556',email: 'jane@smith.com',transactionAgreementContractNumber: '789',startDate: new Date(),endDate: new Date(),consultancyId: consultancy.id};finance = { consultancies: [consultancy], contractors: [contractor] };});it('should get contractors for a given consultancy', () => {const getContractors = new GetContractors(finance);const contractors = getContractors.execute(consultancy.id);expect(contractors.length).toEqual(1);expect(contractors).toContain(contractor);});});
Finally, the GetAllContractors
class:
class GetAllContractors {constructor(private finance: CoachingPracticeFinance) {}execute(): Contractor[] {return this.finance.contractors;}}
And the test suite for GetAllContractors
:
describe('GetAllContractors', () => {let finance: CoachingPracticeFinance;let contractor1: Contractor;let contractor2: Contractor;beforeEach(() => {contractor1 = {loginId: 1,name: 'Jane Smith',phoneNumber: '555-555-5556',email: 'jane@smith.com',transactionAgreementContractNumber: '789',startDate: new Date(),endDate: newDate(),consultancyId: '123'};contractor2 = {loginId: 2,name: 'John Doe',phoneNumber: '555-555-5555',email: 'john@doe.com',transactionAgreementContractNumber: '012',startDate: new Date(),endDate: new Date(),consultancyId: '789'};finance = { consultancies: [], contractors: [contractor1, contractor2] };});it('should get all contractors', () => {const getAllContractors = new GetAllContractors(finance);const contractors = getAllContractors.execute();expect(contractors.length).toEqual(2);expect(contractors).toContain(contractor1);expect(contractors).toContain(contractor2);});});
With this, we have completed all the requested use-case classes and corresponding test suites.
Analyzing the Full Eighth Run
In treating each of the use-cases as independent rather than considering their relatedness, we’re losing some opportunity to share test data contexts - checking that the things are correct when a single consultancy is present with a contractor, and then that things are still correct when there’s a second consultancy with a second contractor.
I think again here, though, I’d like to retain the lack of coupling between the use-cases, even if this would only couple them at the test level.
Let’s see if the style of tests written remains the same with a Ninth and Tenth run of this prompt.
Ninth Zero-Shot Run, Generated Output
Here is a simple design for the CoachingPracticeFinanceTracker
that follows the SOLID principles and the Four Rules of Simple Design. First, we’ll define the basic data structures used by the application.
interface Consultancy {name: string;serviceAgreementContractNumber: string;primaryContact: string;phoneNumber: string;email: string;}interface Contractor {loginId: number;name: string;consultancy: string;phoneNumber: string;email: string;transactionAgreementContractNumber: string;startDate: Date;endDate: Date;}interface CoachingPracticeFinance {consultancies: Consultancy[];contractors: Contractor[];}
We’ll then define classes for each use case, as well as example test suites for each class.
AddConsultancy
class AddConsultancy {constructor(private data: CoachingPracticeFinance) {}execute(consultancy: Consultancy): void {this.data.consultancies.push(consultancy);}}// Jest test suite for AddConsultancydescribe('AddConsultancy', () => {it('should add a new consultancy to the data', () => {const data: CoachingPracticeFinance = { consultancies: [], contractors: [] };const addConsultancy = new AddConsultancy(data);const newConsultancy: Consultancy = {name: 'Example Consultancy',serviceAgreementContractNumber: '1234',primaryContact: 'John Doe',phoneNumber: '555-555-5555',email: 'john@example.com'};addConsultancy.execute(newConsultancy);expect(data.consultancies.length).toBe(1);expect(data.consultancies[0]).toBe(newConsultancy);});});
GetConsultancies
class GetConsultancies {constructor(private data: CoachingPracticeFinance) {}execute(): Consultancy[] {return this.data.consultancies;}}// Jest test suite for GetConsultanciesdescribe('GetConsultancies', () => {it('should return a list of registered consultancies', () => {const data: CoachingPracticeFinance = {consultancies: [{ name: 'Example Consultancy', serviceAgreementContractNumber: '1234', primaryContact: 'John Doe', phoneNumber: '555-555-5555', email: 'john@example.com' },{ name: 'Second Consultancy', serviceAgreementContractNumber: '5678', primaryContact: 'Jane Doe', phoneNumber: '666-666-6666', email: 'jane@example.com' }],contractors: []};const getConsultancies = new GetConsultancies(data);const consultancies = getConsultancies.execute();expect(consultancies.length).toBe(2);});});
Similarly, we can define the remaining use cases.
These initial use case classes and tests lay the foundation for the CoachingPracticeFinanceTracker project. Additional classes and tests can be added to handle the remaining use-cases, following the same structure and design principles.
Tenth Zero-Shot Run, Generated Output
Let’s start by defining our data structures for consultancies and contractors. Following your instructions, we’ll use interfaces to describe composite data types.
interface Consultancy {id: number;name: string;contractNumber: string;primaryContact: string;phoneNumber: string;email: string;}interface Contractor {id: number;name: string;consultancyId: number;phoneNumber: string;email: string;transactionContractNumber: string;startDate: Date;endDate: Date;}interface CoachingPracticeFinance {consultancies: Consultancy[];contractors: Contractor[];}
Next, let’s build the first two use-cases, AddConsultancy
and ListConsultancies
, with the corresponding tests.
class AddConsultancy {private data: CoachingPracticeFinance;constructor(data: CoachingPracticeFinance) {this.data = data;}execute(consultancy: Consultancy): void {this.data.consultancies.push(consultancy);}}class ListConsultancies {private data: CoachingPracticeFinance;constructor(data: CoachingPracticeFinance) {this.data = data;}execute(): Consultancy[] {return this.data.consultancies;}}
And the tests:
import { AddConsultancy, ListConsultancies } from './CoachingPracticeFinanceTracker';describe('AddConsultancy', () => {let data: CoachingPracticeFinance;beforeEach(() => {data = { consultancies: [], contractors: [] };});it('adds a consultancy to the list', () => {const addConsultancy = new AddConsultancy(data);addConsultancy.execute({id: 1,name: 'Consultancy Inc.',contractNumber: '12345',primaryContact: 'John Doe',phoneNumber: '+1 234 567 8901',email: 'john@consultancyinc.com',});expect(data.consultancies).toHaveLength(1);expect(data.consultancies[0].name).toEqual('Consultancy Inc.');});});describe('ListConsultancies', () => {let data: CoachingPracticeFinance;beforeEach(() => {data = {consultancies: [{id: 1,name: 'Consultancy Inc.',contractNumber: '12345',primaryContact: 'John Doe',phoneNumber: '+1 234 567 8901',email: 'john@consultancyinc.com',},],contractors: [],};});it('lists all consultancies', () => {const listConsultancies = new ListConsultancies(data);const consultancies = listConsultancies.execute();expect(consultancies).toHaveLength(1);expect(consultancies[0].name).toEqual('Consultancy Inc.');});});
We’ll now proceed with other use cases in a similar manner. Each class should only be responsible for a single thing (the Single Responsibility Principle) and should have clear interfaces for their behavior (the Interface Segregation Principle). The class dependency (on CoachingPracticeFinance
) should be provided by injection (the Dependency Inversion Principle). The fact that each use case is a class with an execute
method adheres to the Open/Closed Principle, as new functionality can be added without modifying existing code, by adding new classes.
Analyzing the Eighth, Ninth and Tenth Run
Test case generation has stabilized in implementation with the more directive instruction in the prompt.
Notice in the structure of my prompt now how the aspects of my concerns are becoming clearer. How many aspects of concern do I have? How concisely can I specify them? How many tokens am I consuming for context setting compared to how many remain available for generating the output?
Already I’m thinking of how many hundreds of projects had we built for customers in our software consultancy could have been tackled like this. As humans, the project work was boring - they tended to centre around CRUD operations (Create/Read/Update/Delete) and I remember the day we discovered and started to leverage the Rails ActiveAdmin gem.
Are these tasks even worthy of an ML coder?
Will ML coders simply add to the mountain of code-that-should-never-have-been-written in the world?
Exploring the Problem Domain
My thinking is that by investing context tokens into narrowing coding style variation, my chat sessions with the LLM can be more focused on exploring the problem domain rather than fixing those mechanical variations.
For me this is similar to how I use TDD to focus on the problem domain through exploring the layers of API.
As I explore larger systems with the LLM, and move between layers, my hope is that the layer boundaries (eg here’s an interface for an LDAP gateway, use it instead of getting lost in connection mechanics) will continue to help me spare context token space.
I expect this to go well, because this is how right now it enables tool-use. For example, how ChatGPT-4 can know to reach out and call Google, or a webservice. API layers are just tools, and it feels like the right focus, crafting the shapes of them while exploring the problem domain.
Prompt Structure So Far
Here is the outline of our prompt structure so far:
- system prompt (You are an expert software craftsman…)
- Language and Style: …
- Tests: …
- Data Structures: …
- Application Domain: …
- Requirements: …
- Use Cases: …
- Instructions: …
I explore in the Instructions area, and then move instructions over time into Language and Style or Requirements.
Of course, naming things is hard, so I still explore the names of these core structures from time to time. Eg what’s does the LLM think the difference is between Language and Style and Architecture or Code Patterns.
Also, are Use Cases the right framing construct, should I be more elaborate with Examples or should I give the LLM the freedom to explore its own interpretations of ambiguity inherent in Requirements and Use Cases?
Wrapping Up
OK, I’ve walked you through the process I’ve been using to build prompts for generating utility code - so far mostly trivial tools “I’ve” written to help with the odd thing here and there in my life.
In the next post in the series, I’m going to start focusing in on the prompt and its continued evolution.