Shopping Cart: Object Spatial Programming Example#
This example demonstrates how Jac's Object Spatial Programming (OSP) paradigm elegantly solves the classic "Shopping Cart Problem" and the broader Expression Problem through spatial separation of data and behavior.
The Expression Problem#
The Expression Problem is a fundamental challenge in programming language design:
How can we add both new types and new operations to a program without modifying existing code, while maintaining type safety and modularity?
Traditional approaches have inherent limitations: - Object-Oriented Programming: Easy to add new types, difficult to add new operations - Functional Programming: Easy to add new operations, difficult to add new types
Traditional OOP Challenges#
Consider a typical shopping cart implementation with multiple item types requiring various operations:
# Traditional Python approach
class Item:
def calculate_shipping(self):
raise NotImplementedError
def calculate_tax(self):
raise NotImplementedError
def calculate_final_price(self):
raise NotImplementedError
def apply_discount(self):
raise NotImplementedError
class Book(Item):
def calculate_shipping(self):
return 5.0
def calculate_tax(self):
return self.price * 0.08
# ... more methods for each operation
# Adding a new operation requires modifying ALL classes
# Adding a new type requires implementing ALL operations
Problems with Traditional Approach#
- Violation of Open/Closed Principle: Adding operations requires modifying existing classes
- Scattered Logic: Related operations are distributed across multiple classes
- Tight Coupling: Data structure changes affect all operations
- Maintenance Burden: Every new item type must implement every operation
Jac's Spatial Solution#
Jac solves these problems through Object Spatial Programming, which separates data structure from behavioral operations using spatial relationships.
Core Architecture#
The implementation uses a sophisticated modular design with separate objects for different concerns:
# Data structure definition (main.jac)
obj CoreInfo {
has id: str;
has name: str;
has category: str;
has price: float;
has quantity: int;
has tags: list[str] = [];
}
obj Shipping {
has weight: float;
has dimensions: tuple;
has is_fragile: bool;
has shipping_class: str;
has origin_location: str;
}
obj Discounts {
has discount_rate: float;
has eligible_coupons: list[str];
has bulk_discount_threshold: int;
has bulk_discount_amount: float;
}
obj Inventory {
has stock_level: int;
has backorder_allowed: bool;
has estimated_restock_date: str;
}
obj Gift {
has is_gift_eligible: bool;
has gift_wrap_price: float;
has gift_message_allowed: bool;
}
obj Metadata {
has brand: str;
has model_number: str;
has release_date: str;
has echo_friendly: bool;
has digital_download: bool;
}
obj Pricing {
has tax_rate: float = 0;
has price: float = 0;
has discount_rate: float = 0;
has final_price: float = 0;
}
node Item {
has core_info: CoreInfo;
has shipping: Shipping;
has discount: Discounts;
has inventory: Inventory;
has gift_option: Gift;
has metadata: Metadata;
has pricing: Pricing;
}
Walker Declarations#
Operations are declared as walkers that can traverse and operate on the spatial structure:
walker get_base_price {
can get with Item entry;
}
walker calculate_discount {
can calculate with Item entry;
}
walker calculate_tax {
can calculate with Item entry;
}
walker calculate_final_price {
can calculate with Item entry;
}
walker calculate_shipping {
can calculate with Item entry;
}
walker is_eligible_for_free_shipping {
can is_eligible with Item entry;
}
walker apply_coupon {
has coupon_code: str = "";
can apply with Item entry;
}
walker apply_bulk_discount {
has quantity: int;
can apply with Item entry;
}
walker is_gift_eligible {
can is_gift with Item entry;
}
walker calculate_gift_wrap_fee {
can calculate with Item entry;
}
walker get_inventory_status {
can get with Item entry;
}
Behavioral Operations (main.impl.jac)#
Operations are implemented separately using the impl
pattern:
Price and Discount Operations#
impl get_base_price.get {
report here.core_info.price;
}
impl calculate_discount.calculate {
discount_amount = here.core_info.price * here.discount.discount_rate;
report discount_amount;
}
impl calculate_final_price.calculate {
base_price = here.core_info.price;
discount_amount = base_price * here.discount.discount_rate;
tax_amount = (base_price - discount_amount) * 0.08;
final_price = base_price - discount_amount + tax_amount;
report final_price;
}
impl apply_coupon.apply {
if self.coupon_code in here.discount.eligible_coupons {
# Apply 15% additional discount for valid coupons
coupon_discount = here.core_info.price * 0.15;
report coupon_discount;
} else {
report 0.0;
}
}
impl apply_bulk_discount.apply {
if self.quantity >= here.discount.bulk_discount_threshold {
bulk_discount = here.discount.bulk_discount_amount * self.quantity;
report bulk_discount;
} else {
report 0.0;
}
}
Shipping Operations#
impl calculate_shipping.calculate {
base_shipping = 5.0; # Base shipping cost
weight_cost = here.shipping.weight * 0.5; # $0.5 per unit weight
if here.shipping.shipping_class == "express" {
shipping_cost = (base_shipping + weight_cost) * 2;
} elif here.shipping.shipping_class == "overnight" {
shipping_cost = (base_shipping + weight_cost) * 3;
} else {
shipping_cost = base_shipping + weight_cost;
}
if here.shipping.is_fragile {
shipping_cost += 10.0; # Fragile handling fee
}
report shipping_cost;
}
impl is_eligible_for_free_shipping.is_eligible {
# Free shipping for orders over $50 or lightweight items
is_eligible = here.core_info.price >= 50.0 or here.shipping.weight <= 1.0;
report is_eligible;
}
impl calculate_shipping_weight.calculate {
total_weight = here.shipping.weight * here.core_info.quantity;
report total_weight;
}
Tax and Gift Operations#
impl calculate_tax.calculate {
tax_rate = 0.08;
tax_amount = here.core_info.price * tax_rate;
report tax_amount;
}
impl is_gift_eligible.is_gift {
report here.gift_option.is_gift_eligible;
}
impl calculate_gift_wrap_fee.calculate {
if here.gift_option.is_gift_eligible {
report here.gift_option.gift_wrap_price;
} else {
report 0.0;
}
}
impl get_inventory_status.get {
if here.inventory.stock_level > 0 {
report "In Stock";
} elif here.inventory.backorder_allowed {
report "Available for Backorder";
} else {
report "Out of Stock";
}
}
Item Creation and Usage#
walker create_item {
has core_info: CoreInfo;
has shipping: Shipping;
has discount: Discounts;
has inventory: Inventory;
has gift_option: Gift;
has metadata: Metadata;
has tax_rate: float;
can create with `root entry {
final_price = self.core_info.price * (1 - self.discount.discount_rate + self.tax_rate);
pricing = Pricing(self.tax_rate, self.core_info.price, self.discount.discount_rate, final_price);
root ++> Item(self.core_info, self.shipping, self.discount, self.inventory, self.gift_option, self.metadata, pricing);
print([root -->]);
report [root -->];
}
}
with entry {
# Create item components
core_info = CoreInfo(
id="ITEM001",
name="Wireless Headphones",
category="Electronics",
price=199.99,
quantity=2,
tags=["wireless", "bluetooth", "audio"]
);
shipping_info = Shipping(
weight=0.8,
dimensions=(8, 6, 3),
is_fragile=True,
shipping_class="standard",
origin_location="warehouse_a"
);
discount_info = Discounts(
discount_rate=0.10,
eligible_coupons=["SAVE15", "WELCOME"],
bulk_discount_threshold=5,
bulk_discount_amount=25.0
);
inventory_info = Inventory(
stock_level=15,
backorder_allowed=True,
estimated_restock_date="2024-02-15"
);
gift_info = Gift(
is_gift_eligible=True,
gift_wrap_price=5.99,
gift_message_allowed=True
);
metadata_info = Metadata(
brand="AudioTech",
model_number="AT-WH100",
release_date="2023-11-01",
echo_friendly=True,
digital_download=False
);
# Create item
item_creator = create_item(
core_info, shipping_info, discount_info,
inventory_info, gift_info, metadata_info, 0.08
);
item_creator.visit(root);
# Get the created item
item = [root -->][0];
# Use various operations
price_walker = get_base_price();
base_price = price_walker.visit(item);
shipping_walker = calculate_shipping();
shipping_cost = shipping_walker.visit(item);
tax_walker = calculate_tax();
tax_amount = tax_walker.visit(item);
final_price_walker = calculate_final_price();
final_price = final_price_walker.visit(item);
# Check gift eligibility
gift_walker = is_gift_eligible();
is_gift = gift_walker.visit(item);
# Apply coupon
coupon_walker = apply_coupon(coupon_code="SAVE15");
coupon_discount = coupon_walker.visit(item);
# Display results
print(f"Base Price: ${base_price:.2f}");
print(f"Shipping Cost: ${shipping_cost:.2f}");
print(f"Tax Amount: ${tax_amount:.2f}");
print(f"Final Price: ${final_price:.2f}");
print(f"Gift Eligible: {is_gift}");
print(f"Coupon Discount: ${coupon_discount:.2f}");
}
Adding New Operations#
Adding a return policy calculator requires no changes to existing code:
walker calculate_return_fee {
can calculate with Item entry;
}
walker is_returnable {
can check with Item entry;
}
# Implementation
impl calculate_return_fee.calculate {
# Different return fees based on category
if here.core_info.category == "Electronics" {
return_fee = here.core_info.price * 0.05; # 5% restocking fee
} elif here.metadata.digital_download {
return_fee = 0.0; # No returns for digital items
} else {
return_fee = 10.0; # Flat fee for other items
}
report return_fee;
}
impl is_returnable.check {
# Digital downloads are not returnable
if here.metadata.digital_download {
report False;
}
# Items over 30 days old may not be returnable
# (simplified logic for demonstration)
report True;
}
Adding New Item Properties#
Adding warranty information requires only extending the data structure:
obj Warranty {
has duration_months: int;
has coverage_type: str;
has warranty_provider: str;
has extended_warranty_available: bool;
}
# Update Item node
node Item {
# ...existing code...
has warranty: Warranty;
}
# Add warranty-related operations
walker calculate_warranty_cost {
can calculate with Item entry;
}
impl calculate_warranty_cost.calculate {
if here.warranty.extended_warranty_available {
base_cost = here.core_info.price * 0.08; # 8% of item price
duration_factor = here.warranty.duration_months / 12.0;
warranty_cost = base_cost * duration_factor;
report warranty_cost;
} else {
report 0.0;
}
}
Benefits of the Spatial Approach#
Modularity and Separation of Concerns#
- Component-Based Design: Each object handles a specific aspect (shipping, pricing, inventory)
- Operation Encapsulation: Each walker encapsulates a single business operation
- Loose Coupling: Operations don't depend on internal item implementations
Extensibility#
- Easy Operation Addition: New walkers integrate seamlessly
- Flexible Data Extension: Add new object components without breaking existing code
- Scalable Architecture: System grows naturally with business requirements
Maintainability#
- Centralized Logic: Related operations are grouped by walker type
- Clear Responsibilities: Each component has a well-defined purpose
- Implementation Separation: Logic is separated from declarations using
impl
Comparison with Traditional Approaches#
Aspect | Traditional OOP | Jac OSP |
---|---|---|
Adding Operations | Modify all classes | Add new walker + impl |
Adding Properties | Modify class structure | Add new objects |
Logic Location | Mixed in classes | Centralized in walkers |
Business Rules | Scattered | Grouped by operation |
Testing | Complex mocking | Simple walker testing |
Code Reuse | Limited inheritance | Flexible walker composition |
Best Practices#
- Single Responsibility: Each object and walker handles one concern
- Clear Naming: Use descriptive names that reflect business operations
- Modular Design: Break complex data into focused objects
- Implementation Separation: Use
impl
pattern for clean organization - Error Handling: Include validation in walker implementations
- Documentation: Document business rules within walkers
Conclusion#
Jac's Object Spatial Programming paradigm provides an elegant solution to the Expression Problem by:
- Separating Structure from Behavior: Nodes hold structured data, walkers provide operations
- Enabling Modular Design: Component-based architecture with focused responsibilities
- Supporting True Extensibility: Add operations and data without modifying existing code
- Improving Business Logic Organization: Group related operations in walkers
- Facilitating Testing: Walker-based operations are easily testable in isolation
This spatial approach transforms complex business domains into clean, modular, and extensible systems that adapt gracefully to changing requirements while maintaining clear architectural boundaries.
Medical Software Startup#
Welcome to our brand new medical software startup! Our first task is to model the relationship between a Doctor
and a Patient
.
A patient has a doctor. A doctor has a name. Let's code it up!
class Doctor:
def __init__(self, name):
self.name = name
class Patient:
def __init__(self, name, doctor):
self.name = name
self.doctor = doctor # A patient has ONE doctor
# --- Let's test it out ---
dr_house = Doctor("Gregory House")
john_smith = Patient("John Smith", dr_house)
print(f"{john_smith.name}'s doctor is Dr. {john_smith.doctor.name}.")
Life is good. The code is simple, clean, and does exactly what we asked for. We high-five our teammates and go for lunch.
Uh Oh... A New Requirement!#
We get back from lunch and our Project Manager, Brenda, is at our desk.
Brenda: "Hey team! Great work this morning. Just a tiny change: it turns out a Patient
can see many Doctors
(a primary care physician, a specialist, etc.). Also, a Doctor
obviously has many Patients
. Can you update the model?"
Our simple self.doctor
attribute is no longer enough. This is a many-to-many relationship.
"No problem, Brenda!" we say. The most straightforward solution is to use lists.
class Doctor:
def __init__(self, name):
self.name = name
self.patients = [] # A doctor can have many patients
def add_patient(self, patient):
self.patients.append(patient)
class Patient:
def __init__(self, name):
self.name = name
self.doctors = [] # A patient can have many doctors
def add_doctor(self, doctor):
self.doctors.append(doctor)
# --- Let's wire it up ---
dr_house = Doctor("Gregory House")
dr_wilson = Doctor("James Wilson")
john_smith = Patient("John Smith")
john_smith.add_doctor(dr_house)
dr_house.add_patient(john_smith)
john_smith.add_doctor(dr_wilson)
dr_wilson.add_patient(john_smith)
print(f"{john_smith.name}'s doctors are: {', '.join(['Dr. ' + doc.name for doc in john_smith.doctors])}")
Okay, a bit more complex, but manageable. We've handled the many-to-many relationship. We're feeling pretty good about ourselves.
The Relationship Gets Complicated#
Brenda is back. She looks... anxious.
Brenda: "The stakeholders love it! But... they want to actually bill for a visit. So when a Patient
sees a Doctor
, we need to track the date, the diagnosis, and the cost of that specific visit. Where... does that information go?"
Us: "Uhhh..."
Let's think about this.
- Can we put
visit_cost
in thePatient
class? No, a patient has different costs for different visits with different doctors. - Can we put it in the
Doctor
class? No, the cost is different for each patient and each visit.
The information (cost, date, diagnosis) doesn't belong to the Patient
or the Doctor
. It belongs to the interaction between them.
Let's create a new class, Claim
, to represent this relationship between doctor and patient.
class Doctor:
def __init__(self, name):
self.name = name
class Patient:
def __init__(self, name):
self.name = name
class Claim:
def __init__(self, patient, doctor, cost, diagnosis):
self.patient = patient
self.doctor = doctor
self.cost = cost
self.diagnosis = diagnosis
self.status = "Submitted" # The claim has its own lifecycle!
def __repr__(self):
return f"Claim for {self.patient.name} with Dr. {self.doctor.name} for ${self.cost}"
# --- Let's model a visit ---
dr_house = Doctor("Gregory House")
john_smith = Patient("John Smith")
# John visits Dr. House for a checkup
claim1 = Claim(john_smith, dr_house, 250, "Routine Checkup")
print(claim1)
print(f"Status: {claim1.status}")
This is a huge improvement! Our design feels much cleaner. The Claim
object neatly encapsulates all the information about a specific interaction.
Houston, We Have a Communication Problem#
Brenda is back. She's not even trying to hide her panic.
Brenda: "We need InsuranceProviders
! The Provider
has to approve a Claim
. The approval amount depends on the Doctor
's network status and the Patient
's policy. Once the Provider
approves it, the Claim
status must change, the Patient
needs to be notified of their co-pay, and the Doctor
needs to know how much they're getting paid. How do they all talk to each other?!"
Let's try to code this directly.
class InsuranceProvider:
def process_claim(self, claim):
# To do its job, the provider needs to know about the doctor and patient
is_in_network = self.check_network_status(claim.doctor)
deductible_met = self.check_patient_deductible(claim.patient)
if is_in_network and deductible_met:
claim.status = "Approved"
# Now the claim has to tell everyone...
claim.patient.update_balance(20) # Tell patient their co-pay
claim.doctor.update_receivables(230) # Tell doctor they're getting paid
Look at that process_claim
method! The Provider
needs a reference to the Claim
. The Claim
needs references to the Patient
and Doctor
so it can call their methods.
What happens if the Patient
class changes its update_balance
method name? The InsuranceProvider
code breaks! What if the Doctor
wants a different notification? The Claim
class has to be changed.
JAC to the rescue!#
This tight coupling is a classic software design problem. When different parts of your system are too dependent on the internal details of other parts, a small change in one place can cause a cascade of failures elsewhere. Our InsuranceProvider
needs to know the exact method names in Patient
and Doctor
to function.
So, how do we fix this? We can decouple the actions from the data. The data (who a patient is, what a claim costs) can live in a structured, connected way, and the actions (like processing a claim) can be independent agents that walk over this structure.
This is where a graph-based approach using Jac comes in. Instead of objects calling each other's methods directly, we will model our system as a graph and use "walkers" to traverse it.
Step 1: Modeling Data as a Graph#
First, let's get rid of the Python classes and represent everything as nodes (the things) and edges (the relationships).
In Jac, we define the blueprint for our data like this:
# A person is a foundational concept
node Person {
has name: str;
}
# Roles that a Person can have
node Doctor {
has specialty: str;
has receivables: int = 0;
}
node Patient {
has balance: int = 0;
}
# The insurance company
node Insurance {
has name: str;
has fraction: float = 0.8; # How much they cover by default
}
# The claim itself
node Claim {
has cost: int;
has status: str = "Processing";
has insurance_pays: int = 0;
}
Think of nodes
as our nouns. They hold data but don't have complex methods for interacting with other nodes. They just exist.
Next, we define the relationships between them using edges:
# Edges define how nodes are connected
edge has_role {}
edge is_treating {}
edge treated_by {}
edge in_network_with {}
edge has_insurance {}
edge files_claim {}
edge is_processed_by {}
Edges are the verbs. An edge connects two nodes, creating a meaningful link, like (Person) --has_role--> (Doctor)
.
Step 2: Creating an Independent "Processor"#
Remember the problematic process_claim
method inside the InsuranceProvider
class? It was doing too much and was too tightly coupled.
In Jac, we pull that logic out into a walker. A walker is an independent agent that can travel across the graph of nodes and edges to perform actions.
Let's look at the ClaimProcessing
walker. It's designed to do one job: process a single claim.
walker ClaimProcessing {
has claim: Claim;
has fraction: float = 0; # The coverage amount we'll discover
has insurance_pays: int = 0;
# The main logic starts here
can traverse with Claim entry {
# Find the patient who filed this claim
patient = [here <-:files_claim:<-][0];
# Find that patient's insurance and doctor
insurance = [patient ->:has_insurance:->];
doctor = [patient->:treated_by:->][0];
# Check if the doctor is in the insurance network
if len(insurance) > 0 {
# is_connected is a helper that checks if an edge exists
# between two nodes.
if is_connected(doctor, in_network_with, insurance[0]) {
self.fraction = insurance[0].fraction; # Grab the coverage %
here.status = "Approved";
}
}
# Calculate payment and update the claim node
self.insurance_pays = int(self.claim.cost * self.fraction);
here.insurance_pays = self.insurance_pays;
# Now, visit the patient to update their balance
visit [here <-:files_claim:<-];
}
}
Look how clean that is! The walker starts at a Claim
node. It then "walks" along the graph's edges (<-:files_claim:<-
, ->:has_insurance:->
) to find the Patient
, Insurance
, and Doctor
. It doesn't need to have a claim.patient
or claim.doctor
variable passed to it. It discovers the relationships by traversing the graph.
But what about updating the doctor and patient? The walker handles that too, but in a decoupled way.
walker ClaimProcessing {
# ... (previous code) ...
# If the walker visits a Doctor node, this ability activates
can update_doctor with Doctor entry {
here.receivables += self.insurance_pays;
}
# If the walker visits a Patient node, this one activates
can update_patient with Patient entry {
here.balance += self.claim.cost - self.insurance_pays;
}
}
The visit
statement in the main traversal block (visit [here <-:files_claim:<-];
) sends the walker over to the Patient
node that is connected to the claim. When the walker "lands" on that Patient
node, its update_patient
ability automatically fires. We could easily add a similar visit
to the doctor to update their receivables. The core logic in ClaimProcessing
doesn't know or care how the Patient
or Doctor
node is updated; it just sends a visitor. The node itself handles the update.
Step 3: Putting It All Together#
So how do we build this graph and kick off the process? The with entry
block is like our main
function.
with entry {
# 1. Create the nodes
blue_cross = spawn Insurance(name="Blue Cross Health Insurance");
dr_house_person = spawn Person(name="Gregory House");
dr_house_role = spawn Doctor(specialty="Internal Medicine");
john_smith_person = spawn Person(name="John Smith");
john_smith_role = spawn Patient();
# 2. Create the edges (relationships)
dr_house_person +>:has_role:+> dr_house_role; # Dr. House IS A Doctor
john_smith_person +>:has_role:+> john_smith_role; # John Smith IS A Patient
dr_house_person +>:in_network_with:+> blue_cross; # Link doctor to provider
john_smith_person +>:has_insurance:+> blue_cross; # Link patient to provider
john_smith_person +>:treated_by:+> dr_house_person; # Link patient to doctor
# 3. A medical event happens: a claim is created
claim = spawn Claim(cost=250);
john_smith_person +>:files_claim:+> claim; # John files the claim
# 4. Kick off the walkers!
spawn claim with ClaimProcessing(claim=claim);
spawn claim with PrintClaim();
}
And that's it! We've successfully modeled a complex, real-world scenario where information is spread out and multiple parties need to be updated. The key benefits are:
- Decoupling: The
ClaimProcessing
walker doesn't need to know the inner workings ofPatient
orDoctor
. It just visits them. - Clarity: The model (nodes and edges) is separate from the logic (walkers). The graph clearly describes the state of the world.
- Flexibility: If Brenda comes back and says the
Doctor
's notification now needs to be an email, we simply modify theupdate_doctor
ability in the walker. TheClaim
andPatient
code remains untouched.