(Disclosure: I work for Timefold)
OR Tools is a MIPS/SAT solver, while Timefold is a meta-heuristic solver.
As a result, the developer experience is quite different:

- In OR Tools, you need to write your constraints as mathematical equations. You can only provide your own function in a few scenarios, such as calculating the distance between two points. This allows OR Tools to eliminate symmetries and calculate gradients to improve its solution search. In Timefold, your constraints are treated like a black box, so your constraints can use any function and call any library you like (although you shouldn't do things like IO in them). However, since said constraints are a black box, Timefold is unable to eliminate symmetries or calculate gradients.

- In OR Tools, you have very little control in how it does its search. At most, you can select what algorithm/strategy it uses. In Timefold, you have a ton of control in how it does its search: from what moves its tries to adding your own custom phases. If none are configured, it will use sensible defaults. That being said, certain problems strongly benefit from custom moves.

- In OR Tools, you do not need to create custom classes; you create variables using methods on their domain model classes. In Timefold, you need to define your domain model by creating your own classes. This adds initial complexity, but it makes the code more readable: instead of your variable being an int, it is an Employee or a Visit. That being said, it can be difficult for someone to design an initial model, since it requires an understanding of the problem they are solving.

All in all, when using Timefold, you need to think in a more declarative approach (think Prolog, SQL, etc). For example, let say you have a room assignment problem where a teacher can only teach at a single room at once, and minimize room changes. In Timefold, it may look like this:

# Typically would be stored in a parameterization object, but a global var
# is used for brevity
teacher_count = 3

@planning_entity
@dataclass
class Room:
id: Annotated[int, PlanningId]
date: int
name: str
# This can also be a Teacher object, but str is used for brevity here
teacher: Annotated[str, PlanningVariable] = field(default=None)

@planning_solution
@dataclass
class Timetable:
rooms: Annotated[list[Room], PlanningEntityCollectionProperty]
teachers: Annotated[list[str], ValueRangeProvider]
score: Annotated[HardSoftScore, PlanningScore] = field(default=None)

@constraint_provider
def timetable_constraints(cf: ConstraintFactory):
return [
cf.for_each_unique_pair(Room, Joiners.equal(lambda room: room.date), Joiners.equal(lambda room: room.teacher))
.penalize(HardSoftScore.ONE_HARD)
.as_constraint('Teacher time conflict'),
cf.for_each_unique_pair(Room, Joiners.equal(lambda room: room.teacher))
.filter(lambda a, b: a.name != b.name)
.penalize(HardSoftScore.ONE_SOFT)
.as_constraint('Minimize room change')
]

solver_config = SolverConfig(
solution_class=Timetable,
entity_class_list=[Room],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=timetable_constraints
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=5)
)
)

solver_factory = SolverFactory.create(solver_config)
solver = solver_factory.build_solver()
problem: Timetable = build_problem()
solver.solve(problem)

Compared to OR Tools:

teacher_count = 3
problem: Timetable = build_problem()
model = cp_model.CpModel()
objective = []
room_vars = [cp_model.model.new_int_var(0, teacher_count - 1, f'room_assignment_{i}' for i in range(len(problem.rooms)))]
room_vars_by_date = {date: [room_vars[i] for i in range(len(problem.rooms)) if problem.rooms[i].date == date] for date in {room.date for room in problem.rooms}}
room_vars_by_name = {name: [room_vars[i] for i in range(len(problem.rooms)) if problem.rooms[i].name == name] for name in {room.name for room in problem.rooms}}
for date, date_vars in room_vars_by_date.items():
model.AddAllDifferent(date_vars)
for name, name_vars in room_vars_by_name.items():
for assignment_1 in names_vars:
for assignment_2 in names_vars:
if assignment_1 is not assignment_2:
objective.add(assignment_1 != assignment_2)

model.minimize(sum(objective))
solver = cp_model.CpSolver()
status = solver.solve(model)